Compare commits

...

56 Commits

Author SHA1 Message Date
f0a7d97ec8 change bottom form 20 to 8 in selfWrappedContent() 2025-12-18 11:07:22 +05:30
a69246b11e match ui of sheet with existing sheet in projects 2025-12-18 11:00:03 +05:30
f305f7ff41 matches ui of sheet with existing one 2025-12-17 18:34:03 +05:30
73bd5cdc92 removed firebase_ai package from pubsec.yaml 2025-12-17 17:43:02 +05:30
48a4eb2ca7 swiched to intial build.gradle file 2025-12-17 17:31:04 +05:30
2e1b3065df removed gemini bot feature 2025-12-17 16:50:27 +05:30
42c34ad26a imprroved 2025-12-17 16:44:27 +05:30
6b1a64f3b1 added gemini api 2025-12-17 16:44:26 +05:30
74377288eb added gemini api 2025-12-17 16:34:08 +05:30
415f83f877 Merge branch 'Manish_Dev_13/12' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Manish_Dev_13/12 2025-12-17 15:51:50 +05:30
4d012b78f7 removed gemini bot feature 2025-12-17 15:51:42 +05:30
8155468cd4 UI change in Manage Reporting bottom sheet 2025-12-17 15:51:42 +05:30
874c2cff4d implemented assign employee feature for infra project module 2025-12-17 15:51:42 +05:30
a95e0be48b done with update Today's Planned count right after submit of assign task 2025-12-17 15:37:44 +05:30
c9f4795de6 bypass changes to debugg without key.proporties 2025-12-17 15:37:44 +05:30
a6743cfd9b imprroved 2025-12-17 15:37:44 +05:30
b96ed51da2 added gemini api 2025-12-17 15:37:44 +05:30
7da7fadbc9 removed gemini bot feature 2025-12-17 15:11:27 +05:30
fe5cae9889 UI change in Manage Reporting bottom sheet 2025-12-17 12:03:30 +05:30
55f36fac6d implemented assign employee feature for infra project module 2025-12-17 10:37:48 +05:30
81de795e93 refactor: optimize project handling and UI state management across controllers and views 2025-12-16 17:50:21 +05:30
4e577bd7eb enhansed pill tab bat and loading 2025-12-16 17:18:19 +05:30
0ac8998c59 Merge branch 'Manish_Dev_13/12' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Manish_Dev_13/12 2025-12-15 17:19:29 +05:30
c45cb6158e done with update Today's Planned count right after submit of assign task 2025-12-15 17:18:56 +05:30
b2e3398bb1 bypass changes to debugg without key.proporties 2025-12-15 17:18:56 +05:30
2aa6d13a71 imprroved 2025-12-15 17:18:56 +05:30
e2fc81ba0e added gemini api 2025-12-15 17:18:56 +05:30
57634d7bd2 done with update Today's Planned count right after submit of assign task 2025-12-15 17:14:45 +05:30
03082aeea9 added team member list 2025-12-15 10:53:16 +05:30
f6f0cd6790 Merge branch 'Manish_Dev_13/12' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Manish_Dev_13/12 2025-12-13 17:24:36 +05:30
5f1693869d bypass changes to debugg without key.proporties 2025-12-13 17:24:03 +05:30
d4c7eae981 improved logic 2025-12-13 17:24:03 +05:30
9389e081c9 bypass changes to debugg without key.proporties 2025-12-13 17:21:11 +05:30
6cd9fbe57b improved logic 2025-12-13 14:36:54 +05:30
0e79aa4793 imprroved 2025-12-13 14:34:02 +05:30
f0f1ff1a5c Merge branch 'Decription_Feature' of https://git.marcoaiot.com/admin/marco.pms.mobileapp into Gemini_Feature 2025-12-12 17:57:07 +05:30
0fa5a85d79 fixed issues 2025-12-12 17:29:03 +05:30
5c6c6289cd added gemini api 2025-12-12 15:24:37 +05:30
fc099cccb5 removed warnings 2025-12-11 17:52:34 +05:30
22b61b7024 added version checking 2025-12-11 15:43:14 +05:30
e8fd420d51 increased release 2025-12-10 15:35:20 +05:30
1279b0e00f updated packages 2025-12-10 14:54:27 +05:30
03a3c1e06c corrected 2025-12-10 11:36:11 +05:30
1bf676f64a fixed issues 2025-12-09 16:22:55 +05:30
fbfc54159c optimized code 2025-12-08 16:54:03 +05:30
7ce0a8555a added skeleton loader 2025-12-08 16:27:07 +05:30
3603b12f9c added skeleton for the service and infra list 2025-12-08 15:21:40 +05:30
b907e76c12 made chnages in skeleton 2025-12-08 14:53:10 +05:30
406ab30dba made chnages in splash screen 2025-12-08 13:07:15 +05:30
18cb0068e6 made chnages in skeleton loader 2025-12-08 12:50:31 +05:30
307b3ceb96 added nevigation for project teams 2025-12-08 11:53:04 +05:30
c96aa42e81 added condition for no services 2025-12-08 11:37:16 +05:30
b2205c18f4 added empty data card 2025-12-08 11:19:06 +05:30
f937bd849f optimized auth service 2025-12-08 11:07:41 +05:30
28fbc2ad29 optimized the api service 2025-12-08 10:49:53 +05:30
b1741bbb0c added decription 2025-12-06 17:34:01 +05:30
74 changed files with 6217 additions and 5337 deletions

View File

@ -1,4 +1,4 @@
# On Field Work # OnFieldWork.com
A new Flutter project. A new Flutter project.

View File

@ -39,7 +39,7 @@ android {
// Specify your unique Application ID. This identifies your app on Google Play. // Specify your unique Application ID. This identifies your app on Google Play.
applicationId = "com.marcoonfieldwork.aiot" applicationId = "com.marcoonfieldwork.aiot"
// Set minimum and target SDK versions based on Flutter's configuration // Set minimum and target SDK versions based on Flutter's configuration
minSdk = 23 minSdkVersion = flutter.minSdkVersion
targetSdk = flutter.targetSdkVersion targetSdk = flutter.targetSdkVersion
// Set version code and name based on Flutter's configuration (from pubspec.yaml) // Set version code and name based on Flutter's configuration (from pubspec.yaml)
versionCode = flutter.versionCode versionCode = flutter.versionCode

View File

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

View File

@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists zipStorePath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip

View File

@ -18,7 +18,7 @@ pluginManagement {
plugins { plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0" id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.2.21" apply false id "org.jetbrains.kotlin.android" version "2.2.21" apply false
id("com.google.gms.google-services") version "4.4.2" apply false id("com.google.gms.google-services") version "4.4.2" apply false
} }

View File

@ -14,7 +14,7 @@ YELLOW='\033[1;33m'
NC='\033[0m' # No Color NC='\033[0m' # No Color
# App info # App info
APP_NAME="On Field Work" APP_NAME="OnFieldWork.com"
BUILD_DIR="build/app/outputs" BUILD_DIR="build/app/outputs"
echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}" echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}"

View File

@ -5,7 +5,7 @@
<key>CFBundleDevelopmentRegion</key> <key>CFBundleDevelopmentRegion</key>
<string>$(DEVELOPMENT_LANGUAGE)</string> <string>$(DEVELOPMENT_LANGUAGE)</string>
<key>CFBundleDisplayName</key> <key>CFBundleDisplayName</key>
<string>On Field Work</string> <string>OnFieldWork.com</string>
<key>CFBundleExecutable</key> <key>CFBundleExecutable</key>
<string>$(EXECUTABLE_NAME)</string> <string>$(EXECUTABLE_NAME)</string>
<key>CFBundleIdentifier</key> <key>CFBundleIdentifier</key>

View File

@ -8,5 +8,5 @@ class AppConstant {
static int iOSAppVersion = 1; static int iOSAppVersion = 1;
static String version = "1.0.0"; static String version = "1.0.0";
static String get appName => 'On Field Work'; static String get appName => 'OnFieldWork.com';
} }

View File

@ -12,26 +12,21 @@ 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/model/dashboard/purchase_invoice_model.dart';
class DashboardController extends GetxController { class DashboardController extends GetxController {
// Dependencies
final ProjectController projectController = Get.put(ProjectController()); final ProjectController projectController = Get.put(ProjectController());
// ========================= // --------------------------
// 1. STATE VARIABLES // STATE VARIABLES
// ========================= // --------------------------
// Attendance
final roleWiseData = <Map<String, dynamic>>[].obs; final roleWiseData = <Map<String, dynamic>>[].obs;
final attendanceSelectedRange = '15D'.obs; final attendanceSelectedRange = '15D'.obs;
final attendanceIsChartView = true.obs; final attendanceIsChartView = true.obs;
final isAttendanceLoading = false.obs; final isAttendanceLoading = false.obs;
// Project Progress
final projectChartData = <ChartTaskData>[].obs; final projectChartData = <ChartTaskData>[].obs;
final projectSelectedRange = '15D'.obs; final projectSelectedRange = '15D'.obs;
final projectIsChartView = true.obs; final projectIsChartView = true.obs;
final isProjectLoading = false.obs; final isProjectLoading = false.obs;
// Overview Counts
final totalProjects = 0.obs; final totalProjects = 0.obs;
final ongoingProjects = 0.obs; final ongoingProjects = 0.obs;
final isProjectsLoading = false.obs; final isProjectsLoading = false.obs;
@ -44,12 +39,12 @@ class DashboardController extends GetxController {
final inToday = 0.obs; final inToday = 0.obs;
final isTeamsLoading = false.obs; final isTeamsLoading = false.obs;
// Expenses & Reports
final isPendingExpensesLoading = false.obs; final isPendingExpensesLoading = false.obs;
final pendingExpensesData = Rx<PendingExpensesData?>(null); final pendingExpensesData = Rx<PendingExpensesData?>(null);
final isExpenseTypeReportLoading = false.obs; final isExpenseTypeReportLoading = false.obs;
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null); final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
final expenseReportStartDate = final expenseReportStartDate =
DateTime.now().subtract(const Duration(days: 15)).obs; DateTime.now().subtract(const Duration(days: 15)).obs;
final expenseReportEndDate = DateTime.now().obs; final expenseReportEndDate = DateTime.now().obs;
@ -63,21 +58,17 @@ class DashboardController extends GetxController {
final expenseTypes = <ExpenseTypeModel>[].obs; final expenseTypes = <ExpenseTypeModel>[].obs;
final selectedExpenseType = Rx<ExpenseTypeModel?>(null); final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
// Teams/Employees
final isLoadingEmployees = true.obs; final isLoadingEmployees = true.obs;
final employees = <EmployeeModel>[].obs; final employees = <EmployeeModel>[].obs;
final uploadingStates = <String, RxBool>{}.obs; final uploadingStates = <String, RxBool>{}.obs;
// Collection
final isCollectionOverviewLoading = true.obs; final isCollectionOverviewLoading = true.obs;
final collectionOverviewData = Rx<CollectionOverviewData?>(null); final collectionOverviewData = Rx<CollectionOverviewData?>(null);
// =========================
// Purchase Invoice Overview
// =========================
final isPurchaseInvoiceLoading = true.obs; final isPurchaseInvoiceLoading = true.obs;
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null); final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
// Constants
final List<String> ranges = ['7D', '15D', '30D']; final List<String> ranges = const ['7D', '15D', '30D'];
static const _rangeDaysMap = { static const _rangeDaysMap = {
'7D': 7, '7D': 7,
'15D': 15, '15D': 15,
@ -86,19 +77,22 @@ class DashboardController extends GetxController {
'6M': 180 '6M': 180
}; };
// =========================
// 2. COMPUTED PROPERTIES
// =========================
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
// DSO Calculation Constants
static const double _w0_30 = 15.0; static const double _w0_30 = 15.0;
static const double _w30_60 = 45.0; static const double _w30_60 = 45.0;
static const double _w60_90 = 75.0; static const double _w60_90 = 75.0;
static const double _w90_plus = 105.0; static const double _w90_plus = 105.0;
// --------------------------
// LATEST PROJECT ID (for race condition fix)
// --------------------------
String _latestProjectId = '';
// --------------------------
// COMPUTED PROPERTIES
// --------------------------
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
double get calculatedDSO { double get calculatedDSO {
final data = collectionOverviewData.value; final data = collectionOverviewData.value;
if (data == null || data.totalDueAmount == 0) return 0.0; if (data == null || data.totalDueAmount == 0) return 0.0;
@ -111,44 +105,46 @@ class DashboardController extends GetxController {
return weightedDue / data.totalDueAmount; return weightedDue / data.totalDueAmount;
} }
// ========================= // --------------------------
// 3. LIFECYCLE // LIFECYCLE
// ========================= // --------------------------
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
logSafe('DashboardController initialized', level: LogLevel.info); logSafe('DashboardController initialized', level: LogLevel.info);
// Project Selection Listener // --------------------------
// Project change listener
// --------------------------
ever<String>(projectController.selectedProjectId, (id) { ever<String>(projectController.selectedProjectId, (id) {
if (id.isNotEmpty) { if (id.isNotEmpty) {
fetchAllDashboardData(); _latestProjectId = id; // track latest project
fetchTodaysAttendance(id); fetchAllDashboardData(id);
} }
}); });
// Expense Report Date Listener // Expense Report Date Listener
everAll([expenseReportStartDate, expenseReportEndDate], (_) { everAll([expenseReportStartDate, expenseReportEndDate], (_) {
if (projectController.selectedProjectId.value.isNotEmpty) { final id = projectController.selectedProjectId.value;
if (id.isNotEmpty) {
fetchExpenseTypeReport( fetchExpenseTypeReport(
startDate: expenseReportStartDate.value, startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value, endDate: expenseReportEndDate.value,
projectId: id,
); );
} }
}); });
// Chart Range Listeners
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance()); ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
ever(projectSelectedRange, (_) => fetchProjectProgress()); ever(projectSelectedRange, (_) => fetchProjectProgress());
} }
// ========================= // --------------------------
// 4. USER ACTIONS // USER ACTIONS
// ========================= // --------------------------
void updateAttendanceRange(String range) => void updateAttendanceRange(String range) =>
attendanceSelectedRange.value = range; attendanceSelectedRange.value = range;
void updateProjectRange(String range) => projectSelectedRange.value = range; void updateProjectRange(String range) => projectSelectedRange.value = range;
void toggleAttendanceChartView(bool isChart) => void toggleAttendanceChartView(bool isChart) =>
attendanceIsChartView.value = isChart; attendanceIsChartView.value = isChart;
@ -163,7 +159,6 @@ class DashboardController extends GetxController {
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) { void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
selectedMonthlyExpenseDuration.value = duration; selectedMonthlyExpenseDuration.value = duration;
// Efficient Map lookup instead of Switch
const durationMap = { const durationMap = {
MonthlyExpenseDuration.oneMonth: 1, MonthlyExpenseDuration.oneMonth: 1,
MonthlyExpenseDuration.threeMonths: 3, MonthlyExpenseDuration.threeMonths: 3,
@ -176,7 +171,8 @@ class DashboardController extends GetxController {
fetchMonthlyExpenses(); fetchMonthlyExpenses();
} }
Future<void> refreshDashboard() => fetchAllDashboardData(); Future<void> refreshDashboard() =>
fetchAllDashboardData(projectController.selectedProjectId.value);
Future<void> refreshAttendance() => fetchRoleWiseAttendance(); Future<void> refreshAttendance() => fetchRoleWiseAttendance();
Future<void> refreshProjects() => fetchProjectProgress(); Future<void> refreshProjects() => fetchProjectProgress();
Future<void> refreshTasks() async { Future<void> refreshTasks() async {
@ -184,150 +180,78 @@ class DashboardController extends GetxController {
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id); if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
} }
// ========================= // --------------------------
// 5. DATA FETCHING (API) // HELPER: Execute API call
// ========================= // --------------------------
/// Wrapper to reduce try-finally boilerplate for loading states
Future<void> _executeApiCall( Future<void> _executeApiCall(
RxBool loader, Future<void> Function() apiLogic) async { RxBool loaderRx, Future<void> Function() apiLogic) async {
loader.value = true; loaderRx.value = true;
try { try {
await apiLogic(); await apiLogic();
} catch (e, stack) {
logSafe('API Call Failed: $e', level: LogLevel.error, stackTrace: stack);
} finally { } finally {
loader.value = false; loaderRx.value = false;
} }
} }
Future<void> fetchAllDashboardData() async { // --------------------------
final String projectId = projectController.selectedProjectId.value; // API FETCHES
// --------------------------
Future<void> fetchAllDashboardData(String projectId) async {
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
_latestProjectId = projectId;
await Future.wait([ await Future.wait([
fetchRoleWiseAttendance(), fetchRoleWiseAttendance(projectId),
fetchProjectProgress(), fetchProjectProgress(projectId),
fetchDashboardTasks(projectId: projectId), fetchDashboardTasks(projectId: projectId),
fetchDashboardTeams(projectId: projectId), fetchDashboardTeams(projectId: projectId),
fetchPendingExpenses(), fetchPendingExpenses(projectId),
fetchExpenseTypeReport( fetchExpenseTypeReport(
startDate: expenseReportStartDate.value, startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value, endDate: expenseReportEndDate.value,
projectId: projectId,
), ),
fetchMonthlyExpenses(), fetchMonthlyExpenses(projectId: projectId),
fetchMasterData(), fetchMasterData(),
fetchCollectionOverview(), fetchCollectionOverview(projectId),
fetchPurchaseInvoiceOverview(), fetchPurchaseInvoiceOverview(projectId),
fetchTodaysAttendance(projectId),
]); ]);
} }
Future<void> fetchCollectionOverview() async { // --------------------------
final projectId = projectController.selectedProjectId.value; // Each fetch now ignores stale project responses
if (projectId.isEmpty) return; // --------------------------
await _executeApiCall(isCollectionOverviewLoading, () async { Future<void> fetchRoleWiseAttendance([String? projectId]) async {
final response = final id = projectId ?? projectController.selectedProjectId.value;
await ApiService.getCollectionOverview(projectId: projectId);
collectionOverviewData.value =
(response?.success == true) ? response!.data : null;
});
}
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);
}
}
});
}
Future<void> fetchMasterData() async {
try {
final data = await ApiService.getMasterExpenseTypes();
if (data is List) {
expenseTypes.value =
data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
}
} catch (_) {}
}
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
await _executeApiCall(isMonthlyExpenseLoading, () async {
final response = await ApiService.getDashboardMonthlyExpensesApi(
categoryId: categoryId,
months: selectedMonthsCount.value,
);
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;
});
}
Future<void> fetchPendingExpenses() async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
await _executeApiCall(isPendingExpensesLoading, () async {
final response = await ApiService.getPendingExpensesApi(projectId: id);
pendingExpensesData.value =
(response?.success == true) ? response!.data : null;
});
}
Future<void> fetchRoleWiseAttendance() async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return; if (id.isEmpty) return;
final localId = id;
await _executeApiCall(isAttendanceLoading, () async { await _executeApiCall(isAttendanceLoading, () async {
final response = await ApiService.getDashboardAttendanceOverview( final response = await ApiService.getDashboardAttendanceOverview(
id, getAttendanceDays()); id, getAttendanceDays());
roleWiseData.value = if (_latestProjectId != localId) return; // discard stale response
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? []; roleWiseData.assignAll(
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? []);
}); });
} }
Future<void> fetchExpenseTypeReport( Future<void> fetchProjectProgress([String? projectId]) async {
{required DateTime startDate, required DateTime endDate}) async { final id = projectId ?? projectController.selectedProjectId.value;
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return;
await _executeApiCall(isExpenseTypeReportLoading, () async {
final response = await ApiService.getExpenseTypeReportApi(
projectId: id,
startDate: startDate,
endDate: endDate,
);
expenseTypeReportData.value =
(response?.success == true) ? response!.data : null;
});
}
Future<void> fetchProjectProgress() async {
final id = projectController.selectedProjectId.value;
if (id.isEmpty) return; if (id.isEmpty) return;
final localId = id;
await _executeApiCall(isProjectLoading, () async { await _executeApiCall(isProjectLoading, () async {
final response = await ApiService.getProjectProgress( final response = await ApiService.getProjectProgress(
projectId: id, days: getProjectDays()); projectId: id, days: getProjectDays());
if (_latestProjectId != localId) return;
if (response?.success == true) { if (response?.success == true) {
projectChartData.value = response!.data projectChartData.assignAll(response!.data
.map((d) => ChartTaskData.fromProjectData(d)) .map((d) => ChartTaskData.fromProjectData(d))
.toList(); .toList());
} else { } else {
projectChartData.clear(); projectChartData.clear();
} }
@ -335,27 +259,115 @@ class DashboardController extends GetxController {
} }
Future<void> fetchDashboardTasks({required String projectId}) async { Future<void> fetchDashboardTasks({required String projectId}) async {
final localId = projectId;
await _executeApiCall(isTasksLoading, () async { await _executeApiCall(isTasksLoading, () async {
final response = await ApiService.getDashboardTasks(projectId: projectId); final response = await ApiService.getDashboardTasks(projectId: projectId);
if (response?.success == true) { if (_latestProjectId != localId) return;
totalTasks.value = response!.data?.totalTasks ?? 0; totalTasks.value = response?.data?.totalTasks ?? 0;
completedTasks.value = response.data?.completedTasks ?? 0; completedTasks.value = response?.data?.completedTasks ?? 0;
} else {
totalTasks.value = 0;
completedTasks.value = 0;
}
}); });
} }
Future<void> fetchDashboardTeams({required String projectId}) async { Future<void> fetchDashboardTeams({required String projectId}) async {
final localId = projectId;
await _executeApiCall(isTeamsLoading, () async { await _executeApiCall(isTeamsLoading, () async {
final response = await ApiService.getDashboardTeams(projectId: projectId); final response = await ApiService.getDashboardTeams(projectId: projectId);
if (response?.success == true) { if (_latestProjectId != localId) return;
totalEmployees.value = response!.data?.totalEmployees ?? 0; totalEmployees.value = response?.data?.totalEmployees ?? 0;
inToday.value = response.data?.inToday ?? 0; inToday.value = response?.data?.inToday ?? 0;
} else { });
totalEmployees.value = 0; }
inToday.value = 0;
Future<void> fetchPendingExpenses([String? projectId]) async {
final id = projectId ?? projectController.selectedProjectId.value;
if (id.isEmpty) return;
final localId = id;
await _executeApiCall(isPendingExpensesLoading, () async {
final response = await ApiService.getPendingExpensesApi(projectId: id);
if (_latestProjectId != localId) return;
pendingExpensesData.value =
response?.success == true ? response!.data : null;
});
}
Future<void> fetchExpenseTypeReport(
{required DateTime startDate,
required DateTime endDate,
String? projectId}) async {
final id = projectId ?? projectController.selectedProjectId.value;
if (id.isEmpty) return;
final localId = id;
await _executeApiCall(isExpenseTypeReportLoading, () async {
final response = await ApiService.getExpenseTypeReportApi(
projectId: id, startDate: startDate, endDate: endDate);
if (_latestProjectId != localId) return;
expenseTypeReportData.value =
response?.success == true ? response!.data : null;
});
}
Future<void> fetchMonthlyExpenses(
{String? categoryId, String? projectId}) async {
final id = projectId ?? projectController.selectedProjectId.value;
if (id.isEmpty) return;
final localId = id;
await _executeApiCall(isMonthlyExpenseLoading, () async {
final response = await ApiService.getDashboardMonthlyExpensesApi(
categoryId: categoryId, months: selectedMonthsCount.value);
if (_latestProjectId != localId) return;
monthlyExpenseList
.assignAll(response?.success == true ? response!.data : []);
});
}
Future<void> fetchMasterData() async {
await _executeApiCall(false.obs, () async {
final data = await ApiService.getMasterExpenseTypes();
if (data is List)
expenseTypes
.assignAll(data.map((e) => ExpenseTypeModel.fromJson(e)).toList());
});
}
Future<void> fetchCollectionOverview([String? projectId]) async {
final id = projectId ?? projectController.selectedProjectId.value;
if (id.isEmpty) return;
final localId = id;
await _executeApiCall(isCollectionOverviewLoading, () async {
final response = await ApiService.getCollectionOverview(projectId: id);
if (_latestProjectId != localId) return;
collectionOverviewData.value =
response?.success == true ? response!.data : null;
});
}
Future<void> fetchPurchaseInvoiceOverview([String? projectId]) async {
final id = projectId ?? projectController.selectedProjectId.value;
if (id.isEmpty) return;
final localId = id;
await _executeApiCall(isPurchaseInvoiceLoading, () async {
final response =
await ApiService.getPurchaseInvoiceOverview(projectId: id);
if (_latestProjectId != localId) return;
purchaseInvoiceOverviewData.value =
response?.success == true ? response!.data : null;
});
}
Future<void> fetchTodaysAttendance(String projectId) async {
final localId = projectId;
await _executeApiCall(isLoadingEmployees, () async {
final response = await ApiService.getAttendanceForDashboard(projectId);
if (_latestProjectId != localId) return;
employees.assignAll(response ?? []);
for (var emp in employees) {
uploadingStates.putIfAbsent(emp.id, () => false.obs);
} }
}); });
} }

View File

@ -7,20 +7,18 @@ import 'package:on_field_work/model/employees/employee_details_model.dart';
class EmployeesScreenController extends GetxController { class EmployeesScreenController extends GetxController {
/// Data lists /// Data lists
RxList<EmployeeModel> employees = <EmployeeModel>[].obs; RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxList<EmployeeModel> filteredEmployees = <EmployeeModel>[].obs;
Rxn<EmployeeDetailsModel> selectedEmployeeDetails = Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
Rxn<EmployeeDetailsModel>(); Rxn<EmployeeDetailsModel>();
/// Loading states /// Loading states
RxBool isLoading = false.obs; RxBool isLoading = true.obs;
RxBool isLoadingEmployeeDetails = false.obs; RxBool isLoadingEmployeeDetails = false.obs;
/// Selection state /// Selection state
RxBool isAllEmployeeSelected = false.obs; RxBool isAllEmployeeSelected = false.obs;
RxSet<String> selectedEmployeeIds = <String>{}.obs; RxSet<String> selectedEmployeeIds = <String>{}.obs;
/// Upload state tracking (if needed later)
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
RxList<EmployeeModel> selectedEmployeePrimaryManagers = <EmployeeModel>[].obs; RxList<EmployeeModel> selectedEmployeePrimaryManagers = <EmployeeModel>[].obs;
RxList<EmployeeModel> selectedEmployeeSecondaryManagers = RxList<EmployeeModel> selectedEmployeeSecondaryManagers =
<EmployeeModel>[].obs; <EmployeeModel>[].obs;
@ -31,26 +29,51 @@ class EmployeesScreenController extends GetxController {
fetchAllEmployees(); fetchAllEmployees();
} }
/// 🔹 Fetch all employees (no project filter) /// 🔹 Search/Filter Logic
void searchEmployees(String query) {
if (query.isEmpty) {
filteredEmployees.assignAll(employees);
} else {
final searchQuery = query.toLowerCase();
final result = employees
.where((e) =>
e.name.toLowerCase().contains(searchQuery) ||
e.email.toLowerCase().contains(searchQuery) ||
e.phoneNumber.toLowerCase().contains(searchQuery) ||
e.jobRole.toLowerCase().contains(searchQuery))
.toList();
// Sort alphabetically
result
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
filteredEmployees.assignAll(result);
}
}
/// 🔹 Fetch all employees
Future<void> fetchAllEmployees({String? organizationId}) async { Future<void> fetchAllEmployees({String? organizationId}) async {
isLoading.value = true; isLoading.value = true;
update(['employee_screen_controller']);
await _handleApiCall( await _handleApiCall(
() => ApiService.getAllEmployees(organizationId: organizationId), () => ApiService.getAllEmployees(organizationId: organizationId),
onSuccess: (data) { onSuccess: (data) {
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json))); final loadedList =
data.map((json) => EmployeeModel.fromJson(json)).toList();
employees.assignAll(loadedList);
filteredEmployees.assignAll(loadedList);
logSafe( logSafe(
"All Employees fetched: ${employees.length} employees loaded.", "All Employees fetched: ${employees.length} employees loaded.",
level: LogLevel.info, level: LogLevel.info,
); );
// Reset selection states when new data arrives
selectedEmployeeIds.clear(); selectedEmployeeIds.clear();
isAllEmployeeSelected.value = false; isAllEmployeeSelected.value = false;
}, },
onEmpty: () { onEmpty: () {
employees.clear(); employees.clear();
filteredEmployees.clear();
selectedEmployeeIds.clear(); selectedEmployeeIds.clear();
isAllEmployeeSelected.value = false; isAllEmployeeSelected.value = false;
logSafe("No Employee data found or API call failed", logSafe("No Employee data found or API call failed",
@ -90,16 +113,14 @@ class EmployeesScreenController extends GetxController {
isLoadingEmployeeDetails.value = false; isLoadingEmployeeDetails.value = false;
} }
/// Fetch reporting managers for a specific employee from /organization/hierarchy/list/:employeeId /// Fetch reporting managers
Future<void> fetchReportingManagers(String? employeeId) async { Future<void> fetchReportingManagers(String? employeeId) async {
if (employeeId == null || employeeId.isEmpty) return; if (employeeId == null || employeeId.isEmpty) return;
try { try {
// Always clear before new fetch (to avoid mixing old data)
selectedEmployeePrimaryManagers.clear(); selectedEmployeePrimaryManagers.clear();
selectedEmployeeSecondaryManagers.clear(); selectedEmployeeSecondaryManagers.clear();
// Fetch from existing API helper
final data = await ApiService.getOrganizationHierarchyList(employeeId); final data = await ApiService.getOrganizationHierarchyList(employeeId);
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
@ -124,11 +145,8 @@ class EmployeesScreenController extends GetxController {
selectedEmployeeSecondaryManagers.add(emp); selectedEmployeeSecondaryManagers.add(emp);
} }
} }
} catch (_) { } catch (_) {}
// ignore malformed items
}
} }
update(['employee_screen_controller']); update(['employee_screen_controller']);
} catch (e) { } catch (e) {
logSafe("Error fetching reporting managers for $employeeId", logSafe("Error fetching reporting managers for $employeeId",
@ -139,13 +157,13 @@ class EmployeesScreenController extends GetxController {
/// 🔹 Clear all employee data /// 🔹 Clear all employee data
void clearEmployees() { void clearEmployees() {
employees.clear(); employees.clear();
filteredEmployees.clear();
selectedEmployeeIds.clear(); selectedEmployeeIds.clear();
isAllEmployeeSelected.value = false; isAllEmployeeSelected.value = false;
logSafe("Employees cleared", level: LogLevel.info); logSafe("Employees cleared", level: LogLevel.info);
update(['employee_screen_controller']); update(['employee_screen_controller']);
} }
/// 🔹 Generic handler for list API responses
Future<void> _handleApiCall( Future<void> _handleApiCall(
Future<List<dynamic>?> Function() apiCall, { Future<List<dynamic>?> Function() apiCall, {
required Function(List<dynamic>) onSuccess, required Function(List<dynamic>) onSuccess,
@ -168,7 +186,6 @@ class EmployeesScreenController extends GetxController {
} }
} }
/// 🔹 Generic handler for single-object API responses
Future<void> _handleSingleApiCall( Future<void> _handleSingleApiCall(
Future<Map<String, dynamic>?> Function() apiCall, { Future<Map<String, dynamic>?> Function() apiCall, {
required Function(Map<String, dynamic>) onSuccess, required Function(Map<String, dynamic>) onSuccess,

View File

@ -44,21 +44,26 @@ class AddExpenseController extends GetxController {
TextEditingController get noOfPersonsController => controllers[7]; TextEditingController get noOfPersonsController => controllers[7];
TextEditingController get employeeSearchController => controllers[8]; TextEditingController get employeeSearchController => controllers[8];
final List<String> _transactionIdExemptIds = const [
'24e6b0df-7929-47d2-88a3-4cf14c1f28f9',
'48d9b462-5d87-4dec-8dec-2bc943943172',
'f67beee6-6763-4108-922c-03bd86b9178d',
];
// --- Reactive State --- // --- Reactive State ---
final isLoading = false.obs; final isLoading = false.obs;
final isSubmitting = false.obs; final isSubmitting = false.obs;
final isFetchingLocation = false.obs; final isFetchingLocation = false.obs;
final isEditMode = false.obs; final isEditMode = false.obs;
final isSearchingEmployees = false.obs; final isSearchingEmployees = false.obs;
final isTransactionIdExempted = false.obs;
// --- Paid By (Single + Multi Selection Support) --- // --- Paid By (Single + Multi Selection Support) ---
// single selection // single selection
final selectedPaidBy = Rxn<EmployeeModel>(); final selectedPaidBy = Rxn<EmployeeModel>();
// helper setters
// helper setters
void setSelectedPaidBy(EmployeeModel? emp) { void setSelectedPaidBy(EmployeeModel? emp) {
selectedPaidBy.value = emp; selectedPaidBy.value = emp;
} }
@ -66,7 +71,6 @@ class AddExpenseController extends GetxController {
// --- Dropdown Selections & Data --- // --- Dropdown Selections & Data ---
final selectedPaymentMode = Rxn<PaymentModeModel>(); final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>(); final selectedExpenseType = Rxn<ExpenseTypeModel>();
// final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs; final selectedProject = ''.obs;
final selectedTransactionDate = Rxn<DateTime>(); final selectedTransactionDate = Rxn<DateTime>();
@ -93,6 +97,7 @@ class AddExpenseController extends GetxController {
employeeSearchController.addListener( employeeSearchController.addListener(
() => searchEmployees(employeeSearchController.text), () => searchEmployees(employeeSearchController.text),
); );
ever(selectedPaymentMode, (_) => _checkTransactionIdExemption());
} }
@override @override
@ -103,6 +108,12 @@ class AddExpenseController extends GetxController {
super.onClose(); super.onClose();
} }
void _checkTransactionIdExemption() {
final selectedId = selectedPaymentMode.value?.id;
isTransactionIdExempted.value =
selectedId != null && _transactionIdExemptIds.contains(selectedId);
}
// --- Employee Search --- // --- Employee Search ---
Future<void> searchEmployees(String query) async { Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear(); if (query.trim().isEmpty) return employeeSearchResults.clear();
@ -171,6 +182,7 @@ class AddExpenseController extends GetxController {
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']); expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value = selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']); paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
_checkTransactionIdExemption();
} }
Future<void> _setPaidBy(Map<String, dynamic> data) async { Future<void> _setPaidBy(Map<String, dynamic> data) async {
@ -536,6 +548,11 @@ class AddExpenseController extends GetxController {
if (amountController.text.trim().isEmpty) missing.add("Amount"); if (amountController.text.trim().isEmpty) missing.add("Amount");
if (descriptionController.text.trim().isEmpty) missing.add("Description"); if (descriptionController.text.trim().isEmpty) missing.add("Description");
if (!isTransactionIdExempted.value &&
transactionIdController.text.trim().isEmpty) {
missing.add("Transaction ID");
}
if (selectedTransactionDate.value == null) { if (selectedTransactionDate.value == null) {
missing.add("Transaction Date"); missing.add("Transaction Date");
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) { } else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {

View File

@ -4,6 +4,9 @@ import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/model/expense/expense_detail_model.dart'; import 'package:on_field_work/model/expense/expense_detail_model.dart';
import 'package:on_field_work/model/employees/employee_model.dart'; import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
class ExpenseDetailController extends GetxController { class ExpenseDetailController extends GetxController {
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null); final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
@ -16,6 +19,22 @@ class ExpenseDetailController extends GetxController {
bool _isInitialized = false; bool _isInitialized = false;
final employeeSearchController = TextEditingController(); final employeeSearchController = TextEditingController();
final isSearchingEmployees = false.obs; final isSearchingEmployees = false.obs;
// NEW: Holds the logged-in user info for permission checks
EmployeeInfo? employeeInfo;
final RxBool canSubmit = false.obs;
@override
void onInit() {
super.onInit();
_loadEmployeeInfo(); // Load employee info on init
}
void _loadEmployeeInfo() async {
final info = await LocalStorage.getEmployeeInfo();
employeeInfo = info;
}
/// Call this once from the screen (NOT inside build) to initialize /// Call this once from the screen (NOT inside build) to initialize
void init(String expenseId) { void init(String expenseId) {
@ -31,6 +50,36 @@ class ExpenseDetailController extends GetxController {
]); ]);
} }
/// NEW: Logic to check if the current user can submit the expense
void checkPermissionToSubmit() {
final expenseData = expense.value;
if (employeeInfo == null || expenseData == null) {
canSubmit.value = false;
return;
}
// Status ID for 'Submit' (Hardcoded ID from the original screen logic)
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final isCreatedByCurrentUser = employeeInfo?.id == expenseData.createdBy.id;
final nextStatusIds = expenseData.nextStatus.map((e) => e.id).toList();
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
logSafe(
'🐛 Checking submit permission:\n'
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
'🐛 - Expense created by ID: ${expenseData.createdBy.id}\n'
'🐛 - Next Status IDs: $nextStatusIds\n'
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
'🐛 - Final Permission Result: $result',
level: LogLevel.debug,
);
canSubmit.value = result;
}
/// Generic method to handle API calls with loading and error states /// Generic method to handle API calls with loading and error states
Future<T?> _apiCallWrapper<T>( Future<T?> _apiCallWrapper<T>(
Future<T?> Function() apiCall, String operationName) async { Future<T?> Function() apiCall, String operationName) async {
@ -63,6 +112,8 @@ class ExpenseDetailController extends GetxController {
try { try {
expense.value = ExpenseDetailModel.fromJson(result); expense.value = ExpenseDetailModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}"); logSafe("Expense details loaded successfully: ${expense.value?.id}");
// Call permission check after data is loaded
checkPermissionToSubmit();
} catch (e) { } catch (e) {
errorMessage.value = 'Failed to parse expense details: $e'; errorMessage.value = 'Failed to parse expense details: $e';
logSafe("Parse error in fetchExpenseDetails: $e", logSafe("Parse error in fetchExpenseDetails: $e",
@ -75,8 +126,6 @@ class ExpenseDetailController extends GetxController {
} }
} }
// This method seems like a utility and might be better placed in a helper or utility class
// if it's used across multiple controllers. Keeping it here for now as per original code.
List<String> parsePermissionIds(dynamic permissionData) { List<String> parsePermissionIds(dynamic permissionData) {
if (permissionData == null) return []; if (permissionData == null) return [];
if (permissionData is List) { if (permissionData is List) {
@ -131,8 +180,6 @@ class ExpenseDetailController extends GetxController {
allEmployees.clear(); allEmployees.clear();
logSafe("No employees found.", level: LogLevel.warning); logSafe("No employees found.", level: LogLevel.warning);
} }
// `update()` is typically not needed for RxList directly unless you have specific GetBuilder/Obx usage that requires it
// If you are using Obx widgets, `allEmployees.assignAll` will automatically trigger a rebuild.
} }
/// Update expense with reimbursement info and status /// Update expense with reimbursement info and status
@ -191,4 +238,4 @@ class ExpenseDetailController extends GetxController {
return false; return false;
} }
} }
} }

View File

@ -33,13 +33,13 @@ class PaymentRequestController extends GetxController {
try { try {
final response = await ApiService.getExpensePaymentRequestFilterApi(); final response = await ApiService.getExpensePaymentRequestFilterApi();
if (response != null && response.data != null) { if (response != null) {
projects.assignAll(response.data!.projects ?? []); projects.assignAll(response.data.projects);
payees.assignAll(response.data!.payees ?? []); payees.assignAll(response.data.payees);
categories.assignAll(response.data!.expenseCategory ?? []); categories.assignAll(response.data.expenseCategory);
currencies.assignAll(response.data!.currency ?? []); currencies.assignAll(response.data.currency);
statuses.assignAll(response.data!.status ?? []); statuses.assignAll(response.data.status);
createdBy.assignAll(response.data!.createdBy ?? []); createdBy.assignAll(response.data.createdBy);
} else { } else {
logSafe("Payment request filter API returned null", logSafe("Payment request filter API returned null",
level: LogLevel.warning); level: LogLevel.warning);

View File

@ -1,6 +1,7 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/api_service.dart'; import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/model/infra_project/infra_project_details.dart'; import 'package:on_field_work/model/infra_project/infra_project_details.dart';
import 'package:on_field_work/model/infra_project/infra_team_list_model.dart';
class InfraProjectDetailsController extends GetxController { class InfraProjectDetailsController extends GetxController {
final String projectId; final String projectId;
@ -9,25 +10,39 @@ class InfraProjectDetailsController extends GetxController {
var isLoading = true.obs; var isLoading = true.obs;
var projectDetails = Rxn<ProjectData>(); var projectDetails = Rxn<ProjectData>();
var teamList = <ProjectAllocation>[].obs;
var teamLoading = true.obs;
var errorMessage = ''.obs; var errorMessage = ''.obs;
var teamErrorMessage = ''.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
fetchProjectDetails(); fetchProjectDetails();
fetchProjectTeamList();
}
Map<String, List<ProjectAllocation>> get groupedTeamByRole {
final Map<String, List<ProjectAllocation>> map = {};
for (final member in teamList) {
map.putIfAbsent(member.jobRoleId, () => []).add(member);
}
return map;
} }
Future<void> fetchProjectDetails() async { Future<void> fetchProjectDetails() async {
try { try {
isLoading.value = true; isLoading.value = true;
final response = await ApiService.getInfraProjectDetails(projectId: projectId); final response =
await ApiService.getInfraProjectDetails(projectId: projectId);
if (response != null && response.success == true && response.data != null) { if (response != null &&
response.success == true &&
response.data != null) {
projectDetails.value = response.data; projectDetails.value = response.data;
isLoading.value = false; errorMessage.value = '';
} else { } else {
errorMessage.value = response?.message ?? "Failed to load project details"; errorMessage.value =
response?.message ?? "Failed to load project details";
} }
} catch (e) { } catch (e) {
errorMessage.value = "Error fetching project details: $e"; errorMessage.value = "Error fetching project details: $e";
@ -35,4 +50,28 @@ class InfraProjectDetailsController extends GetxController {
isLoading.value = false; isLoading.value = false;
} }
} }
Future<void> fetchProjectTeamList() async {
try {
teamLoading.value = true;
teamErrorMessage.value = '';
final response = await ApiService.getInfraProjectTeamListApi(
projectId: projectId,
includeInactive: false,
);
if (response?.success == true && response!.data.isNotEmpty) {
teamList.assignAll(response.data);
} else {
teamList.clear();
teamErrorMessage.value = response?.message ?? "No team members found.";
}
} catch (e) {
teamList.clear();
teamErrorMessage.value = "Failed to load team members";
} finally {
teamLoading.value = false;
}
}
} }

View File

@ -3,7 +3,7 @@ import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/permission_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/user_permission.dart'; import 'package:on_field_work/model/user_permission.dart';
import 'package:on_field_work/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/model/projects_model.dart'; import 'package:on_field_work/model/projects_model.dart';
@ -51,7 +51,7 @@ class PermissionController extends GetxController {
Future<void> loadData(String token) async { Future<void> loadData(String token) async {
try { try {
isLoading.value = true; isLoading.value = true;
final userData = await PermissionService.fetchAllUserData(token); final userData = await AuthService.fetchAllUserData(token);
_updateState(userData); _updateState(userData);
await _storeData(); await _storeData();
logSafe("Data loaded and state updated successfully."); logSafe("Data loaded and state updated successfully.");

View File

@ -7,14 +7,19 @@ import 'package:on_field_work/helpers/services/storage/local_storage.dart';
class ProjectController extends GetxController { class ProjectController extends GetxController {
RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs; RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs;
RxString selectedProjectId = ''.obs; RxString selectedProjectId = ''.obs;
RxBool isProjectListExpanded = false.obs;
RxBool isProjectSelectionExpanded = false.obs;
RxBool isProjectSelectionExpanded = false.obs;
RxBool isProjectListExpanded = false.obs;
RxBool isProjectDropdownExpanded = false.obs; RxBool isProjectDropdownExpanded = false.obs;
RxBool isLoading = true.obs; RxBool isLoading = true.obs;
RxBool isLoadingProjects = true.obs; RxBool isLoadingProjects = true.obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
// --------------------------
// Current selected project
// --------------------------
GlobalProjectModel? get selectedProject { GlobalProjectModel? get selectedProject {
if (selectedProjectId.value.isEmpty) return null; if (selectedProjectId.value.isEmpty) return null;
return projects.firstWhereOrNull((p) => p.id == selectedProjectId.value); return projects.firstWhereOrNull((p) => p.id == selectedProjectId.value);
@ -26,58 +31,63 @@ class ProjectController extends GetxController {
fetchProjects(); fetchProjects();
} }
// --------------------------
// Clear all projects & UI states
// --------------------------
void clearProjects() { void clearProjects() {
projects.clear(); projects.clear();
selectedProjectId.value = ''; selectedProjectId.value = '';
isProjectSelectionExpanded.value = false; isProjectSelectionExpanded.value = false;
isProjectListExpanded.value = false; isProjectListExpanded.value = false;
isProjectDropdownExpanded.value = false; isProjectDropdownExpanded.value = false;
isLoadingProjects.value = false;
isLoading.value = false; isLoading.value = false;
isLoadingProjects.value = false;
uploadingStates.clear(); uploadingStates.clear();
LocalStorage.saveString('selectedProjectId', ''); LocalStorage.saveString('selectedProjectId', '');
logSafe("Projects cleared and UI states reset."); logSafe("Projects cleared and UI states reset.");
update();
} }
/// Fetches projects and initializes selected project. // --------------------------
// Fetch projects from API
// --------------------------
Future<void> fetchProjects() async { Future<void> fetchProjects() async {
isLoadingProjects.value = true;
isLoading.value = true; isLoading.value = true;
isLoadingProjects.value = true;
final response = await ApiService.getGlobalProjects(); try {
final response = await ApiService.getGlobalProjects();
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
projects.assignAll( projects.assignAll(response.map((json) => GlobalProjectModel.fromJson(json)).toList());
response.map((json) => GlobalProjectModel.fromJson(json)).toList(),
);
String? savedId = LocalStorage.getString('selectedProjectId'); // Load previously saved project
if (savedId != null && projects.any((p) => p.id == savedId)) { String? savedId = LocalStorage.getString('selectedProjectId');
selectedProjectId.value = savedId; if (savedId != null && projects.any((p) => p.id == savedId)) {
selectedProjectId.value = savedId;
} else {
selectedProjectId.value = projects.first.id.toString();
LocalStorage.saveString('selectedProjectId', selectedProjectId.value);
}
logSafe("Projects fetched: ${projects.length}");
} else { } else {
selectedProjectId.value = projects.first.id.toString(); logSafe("No projects found or API call failed.", level: LogLevel.warning);
LocalStorage.saveString('selectedProjectId', selectedProjectId.value);
} }
} catch (e, stack) {
isProjectSelectionExpanded.value = false; logSafe("Error fetching projects: $e", level: LogLevel.error, stackTrace: stack);
logSafe("Projects fetched: ${projects.length}"); } finally {
} else { isLoading.value = false;
logSafe("No Global projects found or API call failed.", level: LogLevel.warning); isLoadingProjects.value = false;
} }
isLoadingProjects.value = false;
isLoading.value = false;
update(['dashboard_controller']);
} }
Future<void> updateSelectedProject(String projectId) async { Future<void> updateSelectedProject(String projectId) async {
if (selectedProjectId.value == projectId) return;
selectedProjectId.value = projectId; selectedProjectId.value = projectId;
await LocalStorage.saveString('selectedProjectId', projectId); await LocalStorage.saveString('selectedProjectId', projectId);
logSafe("Selected project updated to $projectId"); logSafe("Selected project updated to $projectId");
update(['selected_project']); isProjectSelectionExpanded.value = false;
update();
} }
} }

View File

@ -12,7 +12,8 @@ class DailyTaskPlanningController extends GetxController {
RxList<EmployeeModel> employees = <EmployeeModel>[].obs; RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs; RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
List<EmployeeModel> allEmployeesCache = []; List<EmployeeModel> allEmployeesCache = [];
List<TaskPlanningDetailsModel> dailyTasks = []; RxList<TaskPlanningDetailsModel> dailyTasks =
<TaskPlanningDetailsModel>[].obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator(); MyFormValidator basicValidator = MyFormValidator();
@ -27,6 +28,7 @@ class DailyTaskPlanningController extends GetxController {
RxMap<String, RxBool> buildingLoadingStates = <String, RxBool>{}.obs; RxMap<String, RxBool> buildingLoadingStates = <String, RxBool>{}.obs;
final Set<String> buildingsWithDetails = <String>{}; final Set<String> buildingsWithDetails = <String>{};
RxMap<String, RxDouble> todaysAssignedMap = <String, RxDouble>{}.obs;
@override @override
void onInit() { void onInit() {
super.onInit(); super.onInit();
@ -72,6 +74,8 @@ class DailyTaskPlanningController extends GetxController {
required int plannedTask, required int plannedTask,
required String description, required String description,
required List<String> taskTeam, required List<String> taskTeam,
required String buildingId,
required String projectId,
DateTime? assignmentDate, DateTime? assignmentDate,
String? organizationId, String? organizationId,
String? serviceId, String? serviceId,
@ -93,6 +97,8 @@ class DailyTaskPlanningController extends GetxController {
if (response == true) { if (response == true) {
logSafe("Task assigned successfully", level: LogLevel.info); logSafe("Task assigned successfully", level: LogLevel.info);
await fetchBuildingInfra(buildingId, projectId, serviceId);
showAppSnackbar( showAppSnackbar(
title: "Success", title: "Success",
message: "Task assigned successfully!", message: "Task assigned successfully!",
@ -123,18 +129,17 @@ class DailyTaskPlanningController extends GetxController {
final infraData = infraResponse?['data'] as List<dynamic>?; final infraData = infraResponse?['data'] as List<dynamic>?;
if (infraData == null || infraData.isEmpty) { if (infraData == null || infraData.isEmpty) {
dailyTasks = []; dailyTasks.clear(); //reactive clear
return; return;
} }
// Filter buildings with 0 planned & completed work
final filteredBuildings = infraData.where((b) { final filteredBuildings = infraData.where((b) {
final planned = (b['plannedWork'] as num?)?.toDouble() ?? 0; final planned = (b['plannedWork'] as num?)?.toDouble() ?? 0;
final completed = (b['completedWork'] as num?)?.toDouble() ?? 0; final completed = (b['completedWork'] as num?)?.toDouble() ?? 0;
return planned > 0 || completed > 0; return planned > 0 || completed > 0;
}).toList(); }).toList();
dailyTasks = filteredBuildings.map((buildingJson) { final mapped = filteredBuildings.map((buildingJson) {
final building = Building( final building = Building(
id: buildingJson['id'], id: buildingJson['id'],
name: buildingJson['buildingName'], name: buildingJson['buildingName'],
@ -157,30 +162,31 @@ class DailyTaskPlanningController extends GetxController {
); );
}).toList(); }).toList();
dailyTasks.assignAll(mapped);
buildingLoadingStates.clear(); buildingLoadingStates.clear();
buildingsWithDetails.clear(); buildingsWithDetails.clear();
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching daily task data", logSafe(
level: LogLevel.error, error: e, stackTrace: stack); "Error fetching daily task data",
level: LogLevel.error,
error: e,
stackTrace: stack,
);
} finally { } finally {
isFetchingTasks.value = false; isFetchingTasks.value = false;
update();
} }
} }
/// Fetch full infra for a single building (floors, workAreas, workItems). /// Fetch full infra for a single building (lazy)
/// Called lazily when user expands a building in the UI.
Future<void> fetchBuildingInfra( Future<void> fetchBuildingInfra(
String buildingId, String projectId, String? serviceId) async { String buildingId, String projectId, String? serviceId) async {
if (buildingId.isEmpty) return; if (buildingId.isEmpty) return;
// mark loading
buildingLoadingStates.putIfAbsent(buildingId, () => true.obs); buildingLoadingStates.putIfAbsent(buildingId, () => true.obs);
buildingLoadingStates[buildingId]!.value = true; buildingLoadingStates[buildingId]!.value = true; // Rx change is enough
update();
try { try {
// Re-use getInfraDetails and find the building entry for the requested buildingId
final infraResponse = final infraResponse =
await ApiService.getInfraDetails(projectId, serviceId: serviceId); await ApiService.getInfraDetails(projectId, serviceId: serviceId);
final infraData = infraResponse?['data'] as List<dynamic>? ?? []; final infraData = infraResponse?['data'] as List<dynamic>? ?? [];
@ -196,7 +202,6 @@ class DailyTaskPlanningController extends GetxController {
return; return;
} }
// Build floors & workAreas for this building
final building = Building( final building = Building(
id: buildingJson['id'], id: buildingJson['id'],
name: buildingJson['buildingName'], name: buildingJson['buildingName'],
@ -211,7 +216,7 @@ class DailyTaskPlanningController extends GetxController {
return WorkArea( return WorkArea(
id: areaJson['id'], id: areaJson['id'],
areaName: areaJson['areaName'], areaName: areaJson['areaName'],
workItems: [], // will populate later workItems: [],
); );
}).toList(), }).toList(),
); );
@ -220,7 +225,6 @@ class DailyTaskPlanningController extends GetxController {
completedWork: (buildingJson['completedWork'] as num?)?.toDouble() ?? 0, completedWork: (buildingJson['completedWork'] as num?)?.toDouble() ?? 0,
); );
// For each workArea, fetch its work items and populate
await Future.wait( await Future.wait(
building.floors.expand((f) => f.workAreas).map((area) async { building.floors.expand((f) => f.workAreas).map((area) async {
try { try {
@ -255,7 +259,6 @@ class DailyTaskPlanningController extends GetxController {
} }
})); }));
// Merge/replace the building into dailyTasks
bool merged = false; bool merged = false;
for (var t in dailyTasks) { for (var t in dailyTasks) {
final idx = t.buildings final idx = t.buildings
@ -267,7 +270,6 @@ class DailyTaskPlanningController extends GetxController {
} }
} }
if (!merged) { if (!merged) {
// If not present, add a new TaskPlanningDetailsModel wrapper (fallback)
dailyTasks.add(TaskPlanningDetailsModel( dailyTasks.add(TaskPlanningDetailsModel(
id: building.id, id: building.id,
name: building.name, name: building.name,
@ -280,7 +282,6 @@ class DailyTaskPlanningController extends GetxController {
)); ));
} }
// Mark as loaded
buildingsWithDetails.add(buildingId.toString()); buildingsWithDetails.add(buildingId.toString());
} catch (e, stack) { } catch (e, stack) {
logSafe("Error fetching infra for building $buildingId", logSafe("Error fetching infra for building $buildingId",
@ -288,7 +289,7 @@ class DailyTaskPlanningController extends GetxController {
} finally { } finally {
buildingLoadingStates.putIfAbsent(buildingId, () => false.obs); buildingLoadingStates.putIfAbsent(buildingId, () => false.obs);
buildingLoadingStates[buildingId]!.value = false; buildingLoadingStates[buildingId]!.value = false;
update(); update(); // dailyTasks mutated
} }
} }
@ -361,7 +362,7 @@ class DailyTaskPlanningController extends GetxController {
} }
} finally { } finally {
isFetchingEmployees.value = false; isFetchingEmployees.value = false;
update(); // no update(): RxLists/RxBools notify observers
} }
} }
} }

View File

@ -1,14 +1,12 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart'; import 'package:on_field_work/model/tenant/tenant_list_model.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
class TenantSelectionController extends GetxController { class TenantSelectionController extends GetxController {
final TenantService _tenantService = TenantService();
// Tenant list // Tenant list
final tenants = <Tenant>[].obs; final tenants = <Tenant>[].obs;
@ -32,10 +30,11 @@ class TenantSelectionController extends GetxController {
isLoading.value = true; isLoading.value = true;
isAutoSelecting.value = true; // show splash during auto-selection isAutoSelecting.value = true; // show splash during auto-selection
try { try {
final data = await _tenantService.getTenants(); final data = await AuthService.getTenants();
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
tenants.clear(); tenants.clear();
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning); logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
await LocalStorage.logout();
return; return;
} }
@ -87,7 +86,7 @@ class TenantSelectionController extends GetxController {
try { try {
isLoading.value = true; isLoading.value = true;
final success = await _tenantService.selectTenant(tenantId); final success = await AuthService.selectTenant(tenantId);
if (!success) { if (!success) {
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
@ -99,7 +98,7 @@ class TenantSelectionController extends GetxController {
// Update tenant & persist // Update tenant & persist
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant); AuthService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId; selectedTenantId.value = tenantId;
await LocalStorage.setRecentTenantId(tenantId); await LocalStorage.setRecentTenantId(tenantId);
@ -131,6 +130,6 @@ class TenantSelectionController extends GetxController {
/// Clear tenant selection /// Clear tenant selection
void _clearSelection() { void _clearSelection() {
selectedTenantId.value = null; selectedTenantId.value = null;
TenantService.currentTenant = null; AuthService.currentTenant = null;
} }
} }

View File

@ -1,13 +1,12 @@
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart'; import 'package:on_field_work/model/tenant/tenant_list_model.dart';
import 'package:on_field_work/helpers/widgets/my_snackbar.dart'; import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
class TenantSwitchController extends GetxController { class TenantSwitchController extends GetxController {
final TenantService _tenantService = TenantService();
final tenants = <Tenant>[].obs; final tenants = <Tenant>[].obs;
final isLoading = false.obs; final isLoading = false.obs;
@ -23,7 +22,7 @@ class TenantSwitchController extends GetxController {
Future<void> loadTenants() async { Future<void> loadTenants() async {
isLoading.value = true; isLoading.value = true;
try { try {
final data = await _tenantService.getTenants(); final data = await AuthService.getTenants();
if (data == null || data.isEmpty) { if (data == null || data.isEmpty) {
tenants.clear(); tenants.clear();
logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning); logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning);
@ -33,7 +32,7 @@ class TenantSwitchController extends GetxController {
tenants.value = data.map((e) => Tenant.fromJson(e)).toList(); tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
// Keep current tenant as selected // Keep current tenant as selected
selectedTenantId.value = TenantService.currentTenant?.id; selectedTenantId.value = AuthService.currentTenant?.id;
} catch (e, st) { } catch (e, st) {
logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st); logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st);
showAppSnackbar( showAppSnackbar(
@ -48,11 +47,11 @@ class TenantSwitchController extends GetxController {
/// Switch to a different tenant and navigate fully /// Switch to a different tenant and navigate fully
Future<void> switchTenant(String tenantId) async { Future<void> switchTenant(String tenantId) async {
if (TenantService.currentTenant?.id == tenantId) return; if (AuthService.currentTenant?.id == tenantId) return;
isLoading.value = true; isLoading.value = true;
try { try {
final success = await _tenantService.selectTenant(tenantId); final success = await AuthService.selectTenant(tenantId);
if (!success) { if (!success) {
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning); logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
@ -64,7 +63,7 @@ class TenantSwitchController extends GetxController {
} }
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
TenantService.setSelectedTenant(selectedTenant); AuthService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId; selectedTenantId.value = tenantId;
// Persist recent tenant // Persist recent tenant

View File

@ -3,8 +3,7 @@ class ApiEndpoints {
// static const String baseUrl = "https://api.marcoaiot.com/api"; // static const String baseUrl = "https://api.marcoaiot.com/api";
// static const String baseUrl = "https://devapi.marcoaiot.com/api"; // static const String baseUrl = "https://devapi.marcoaiot.com/api";
// static const String baseUrl = "https://mapi.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"; static const String getMasterCurrencies = "/Master/currencies/list";
static const String getMasterExpensesCategories = static const String getMasterExpensesCategories =
@ -48,7 +47,8 @@ class ApiEndpoints {
static const String getProjects = "/project/list"; static const String getProjects = "/project/list";
static const String getGlobalProjects = "/project/list/basic"; static const String getGlobalProjects = "/project/list/basic";
static const String getTodaysAttendance = "/attendance/project/team"; static const String getTodaysAttendance = "/attendance/project/team";
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId"; static const String getAttendanceForDashboard =
"/dashboard/get/attendance/employee/:projectId";
static const String getAttendanceLogs = "/attendance/project/log"; static const String getAttendanceLogs = "/attendance/project/log";
static const String getAttendanceLogView = "/attendance/log/attendance"; static const String getAttendanceLogView = "/attendance/log/attendance";
static const String getRegularizationLogs = "/attendance/regularize"; static const String getRegularizationLogs = "/attendance/regularize";
@ -142,7 +142,6 @@ class ApiEndpoints {
static const String manageOrganizationHierarchy = static const String manageOrganizationHierarchy =
"/organization/hierarchy/manage"; "/organization/hierarchy/manage";
// Service Project Module API Endpoints // Service Project Module API Endpoints
static const String getServiceProjectsList = "/serviceproject/list"; static const String getServiceProjectsList = "/serviceproject/list";
static const String getServiceProjectDetail = "/serviceproject/details"; static const String getServiceProjectDetail = "/serviceproject/details";
@ -151,10 +150,14 @@ class ApiEndpoints {
"/serviceproject/job/details"; "/serviceproject/job/details";
static const String editServiceProjectJob = "/serviceproject/job/edit"; static const String editServiceProjectJob = "/serviceproject/job/edit";
static const String createServiceProjectJob = "/serviceproject/job/create"; static const String createServiceProjectJob = "/serviceproject/job/create";
static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance"; static const String serviceProjectUpateJobAttendance =
static const String serviceProjectUpateJobAttendanceLog = "/serviceproject/job/attendance/log"; "/serviceproject/job/attendance";
static const String getServiceProjectUpateJobAllocationList = "/serviceproject/get/allocation/list"; static const String serviceProjectUpateJobAttendanceLog =
static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation"; "/serviceproject/job/attendance/log";
static const String getServiceProjectUpateJobAllocationList =
"/serviceproject/get/allocation/list";
static const String manageServiceProjectUpateJobAllocation =
"/serviceproject/manage/allocation";
static const String getTeamRoles = "/master/team-roles/list"; static const String getTeamRoles = "/master/team-roles/list";
static const String getServiceProjectBranches = "/serviceproject/branch/list"; static const String getServiceProjectBranches = "/serviceproject/branch/list";
@ -167,4 +170,6 @@ class ApiEndpoints {
// Infra Project Module API Endpoints // Infra Project Module API Endpoints
static const String getInfraProjectsList = "/project/list"; static const String getInfraProjectsList = "/project/list";
static const String getInfraProjectDetail = "/project/details"; static const String getInfraProjectDetail = "/project/details";
static const String getInfraProjectTeamList = "/project/allocation";
static const String assignInfraProjectAllocation = "/project/allocation";
} }

File diff suppressed because it is too large Load Diff

View File

@ -38,11 +38,16 @@ Future<void> initializeApp() async {
} }
} }
/// ---------------------------------------------------------------------------
/// 🔹 AUTH TOKEN HANDLER
/// ---------------------------------------------------------------------------
Future<void> _handleAuthTokens() async { Future<void> _handleAuthTokens() async {
final refreshToken = await LocalStorage.getRefreshToken(); final refreshToken = await LocalStorage.getRefreshToken();
if (refreshToken?.isNotEmpty ?? false) { if (refreshToken?.isNotEmpty ?? false) {
logSafe("🔁 Refresh token found. Attempting to refresh JWT..."); logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
final success = await AuthService.refreshToken(); final success = await AuthService.refreshToken();
if (!success) { if (!success) {
logSafe("⚠️ Refresh token invalid or expired. User must login again."); logSafe("⚠️ Refresh token invalid or expired. User must login again.");
} }
@ -51,43 +56,67 @@ Future<void> _handleAuthTokens() async {
} }
} }
/// ---------------------------------------------------------------------------
/// 🔹 UI SETUP
/// ---------------------------------------------------------------------------
Future<void> _setupUI() async { Future<void> _setupUI() async {
setPathUrlStrategy(); setPathUrlStrategy();
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge); await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
logSafe("💡 UI setup completed with default system behavior."); logSafe("💡 UI setup completed.");
} }
/// ---------------------------------------------------------------------------
/// 🔹 FIREBASE + GEMINI SETUP
/// ---------------------------------------------------------------------------
Future<void> _setupFirebase() async { Future<void> _setupFirebase() async {
// Firebase Core
await Firebase.initializeApp(); await Firebase.initializeApp();
logSafe("💡 Firebase initialized."); logSafe("🔥 Firebase initialized.");
} }
/// ---------------------------------------------------------------------------
/// 🔹 LOCAL STORAGE SETUP
/// ---------------------------------------------------------------------------
Future<void> _setupLocalStorage() async { Future<void> _setupLocalStorage() async {
if (!LocalStorage.isInitialized) { if (!LocalStorage.isInitialized) {
await LocalStorage.init(); await LocalStorage.init();
logSafe("💡 Local storage initialized."); logSafe("💾 Local storage initialized.");
} else { } else {
logSafe(" Local storage already initialized, skipping."); logSafe(" Local storage already initialized. Skipping.");
} }
} }
/// ---------------------------------------------------------------------------
/// 🔹 DEVICE INFO
/// ---------------------------------------------------------------------------
Future<void> _setupDeviceInfo() async { Future<void> _setupDeviceInfo() async {
final deviceInfoService = DeviceInfoService(); final deviceInfoService = DeviceInfoService();
await deviceInfoService.init(); await deviceInfoService.init();
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
logSafe("📱 Device Info Loaded: ${deviceInfoService.deviceData}");
} }
/// ---------------------------------------------------------------------------
/// 🔹 THEME SETUP
/// ---------------------------------------------------------------------------
Future<void> _setupTheme() async { Future<void> _setupTheme() async {
await ThemeCustomizer.init(); await ThemeCustomizer.init();
logSafe("💡 Theme customizer initialized."); logSafe("🎨 Theme customizer initialized.");
} }
/// ---------------------------------------------------------------------------
/// 🔹 FIREBASE CLOUD MESSAGING (PUSH)
/// ---------------------------------------------------------------------------
Future<void> _setupFirebaseMessaging() async { Future<void> _setupFirebaseMessaging() async {
await FirebaseNotificationService().initialize(); await FirebaseNotificationService().initialize();
logSafe("💡 Firebase Messaging initialized."); logSafe("📨 Firebase Messaging initialized.");
} }
/// ---------------------------------------------------------------------------
/// 🔹 FINAL APP STYLE
/// ---------------------------------------------------------------------------
void _finalizeAppStyle() { void _finalizeAppStyle() {
AppStyle.init(); AppStyle.init();
logSafe("💡 AppStyle initialized."); logSafe("🎯 AppStyle initialized.");
} }

View File

@ -1,27 +1,47 @@
import 'dart:convert'; import 'dart:convert';
import 'package:http/http.dart' as http; import 'package:http/http.dart' as http;
import 'package:get/get.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart'; import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/utils/encryption_helper.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
import 'package:on_field_work/model/user_permission.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/model/projects_model.dart';
// Enum for standardizing HTTP methods within the service
enum _HttpMethod { get, post }
class AuthService { class AuthService {
static const String _baseUrl = ApiEndpoints.baseUrl; static const String _baseUrl = ApiEndpoints.baseUrl;
static const Map<String, String> _headers = { static const Map<String, String> _defaultHeaders = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
// AuthService properties
static bool isLoggedIn = false; static bool isLoggedIn = false;
/* -------------------------------------------------------------------------- */
/* Logout API */ // TenantService properties
/* -------------------------------------------------------------------------- */ static Tenant? currentTenant;
// PermissionService properties
static final Map<String, Map<String, dynamic>> _userDataCache = {};
/* -------------------------------------------------------------------------- */
/* AUTH METHODS                                */
/* -------------------------------------------------------------------------- */
/// Logs the user out by calling the logout API.
static Future<bool> logoutApi(String refreshToken, String fcmToken) async { static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
try { try {
final body = { final body = {"refreshToken": refreshToken, "fcmToken": fcmToken};
"refreshToken": refreshToken, final response = await _networkRequest(
"fcmToken": fcmToken, path: "/auth/logout",
}; method: _HttpMethod.post,
body: body,
final response = await _post("/auth/logout", body); );
if (response != null && response['statusCode'] == 200) { if (response != null && response['statusCode'] == 200) {
logSafe("✅ Logout API successful"); logSafe("✅ Logout API successful");
@ -37,10 +57,7 @@ class AuthService {
} }
} }
/* -------------------------------------------------------------------------- */ /// Registers or updates the Firebase Cloud Messaging token.
/* Public Methods */
/* -------------------------------------------------------------------------- */
static Future<bool> registerDeviceToken(String fcmToken) async { static Future<bool> registerDeviceToken(String fcmToken) async {
final token = await LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
if (token == null || token.isEmpty) { if (token == null || token.isEmpty) {
@ -50,38 +67,36 @@ class AuthService {
} }
final body = {"fcmToken": fcmToken}; final body = {"fcmToken": fcmToken};
final headers = { final response = await _networkRequest(
..._headers, path: "/auth/set/device-token",
'Authorization': 'Bearer $token', method: _HttpMethod.post,
}; body: body,
final endpoint = "$_baseUrl/auth/set/device-token"; authToken: token,
);
// 🔹 Log request details if (response != null && response['success'] == true) {
logSafe("📡 Device Token API Request");
logSafe("➡️ Endpoint: $endpoint");
logSafe("➡️ Headers: ${jsonEncode(headers)}");
logSafe("➡️ Payload: ${jsonEncode(body)}");
final data = await _post("/auth/set/device-token", body, authToken: token);
if (data != null && data['success'] == true) {
logSafe("✅ Device token registered successfully."); logSafe("✅ Device token registered successfully.");
return true; return true;
} }
logSafe("⚠️ Failed to register device token: ${data?['message']}", logSafe("⚠️ Failed to register device token: ${response?['message']}",
level: LogLevel.warning); level: LogLevel.warning);
return false; return false;
} }
/// Handles user login with email/password.
/// Returns error map on failure, or null on success.
static Future<Map<String, String>?> loginUser( static Future<Map<String, String>?> loginUser(
Map<String, dynamic> data) async { Map<String, dynamic> data) async {
logSafe("Attempting login..."); logSafe("Attempting login...");
logSafe("Login payload (raw): $data"); final responseData = await _networkRequest(
logSafe("Login payload (JSON): ${jsonEncode(data)}"); path: "/auth/app/login",
method: _HttpMethod.post,
body: data,
);
final responseData = await _post("/auth/app/login", data); if (responseData == null) {
if (responseData == null)
return {"error": "Network error. Please check your connection."}; return {"error": "Network error. Please check your connection."};
}
if (responseData['data'] != null) { if (responseData['data'] != null) {
await _handleLoginSuccess(responseData['data']); await _handleLoginSuccess(responseData['data']);
@ -93,9 +108,10 @@ class AuthService {
return {"error": responseData['message'] ?? "Unexpected error occurred"}; return {"error": responseData['message'] ?? "Unexpected error occurred"};
} }
/// Refreshes the JWT access token using the refresh token.
static Future<bool> refreshToken() async { static Future<bool> refreshToken() async {
final accessToken = LocalStorage.getJwtToken(); final accessToken = await LocalStorage.getJwtToken();
final refreshToken = LocalStorage.getRefreshToken(); final refreshToken = await LocalStorage.getRefreshToken();
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) { if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
logSafe("Missing access or refresh token.", level: LogLevel.warning); logSafe("Missing access or refresh token.", level: LogLevel.warning);
@ -103,24 +119,22 @@ class AuthService {
} }
final body = {"token": accessToken, "refreshToken": refreshToken}; final body = {"token": accessToken, "refreshToken": refreshToken};
final data = await _post("/auth/refresh-token", body); final data = await _networkRequest(
if (data != null && data['success'] == true) { path: "/auth/refresh-token",
method: _HttpMethod.post,
body: body,
);
if (data != null && data['success'] == true && data['data'] != null) {
await LocalStorage.setJwtToken(data['data']['token']); await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']); await LocalStorage.setRefreshToken(data['data']['refreshToken']);
await LocalStorage.setLoggedInUser(true); await LocalStorage.setLoggedInUser(true);
logSafe("Token refreshed successfully."); logSafe("Token refreshed successfully.");
// 🔹 Retry FCM token registration after token refresh final newFcmToken = await LocalStorage.getFcmToken();
final newFcmToken = LocalStorage.getFcmToken();
if (newFcmToken?.isNotEmpty ?? false) { if (newFcmToken?.isNotEmpty ?? false) {
final success = await registerDeviceToken(newFcmToken!); await registerDeviceToken(newFcmToken!);
logSafe(
success
? "✅ FCM token re-registered after JWT refresh."
: "⚠️ Failed to register FCM token after JWT refresh.",
level: success ? LogLevel.info : LogLevel.warning);
} }
return true; return true;
} }
logSafe("Refresh token failed: ${data?['message']}", logSafe("Refresh token failed: ${data?['message']}",
@ -128,35 +142,29 @@ class AuthService {
return false; return false;
} }
/// Initiates the forgot password process.
static Future<Map<String, String>?> forgotPassword(String email) => static Future<Map<String, String>?> forgotPassword(String email) =>
_wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}), _wrapErrorHandling(
() => _networkRequest(
path: "/auth/forgot-password",
method: _HttpMethod.post,
body: {"email": email},
),
successCondition: (data) => data['success'] == true, successCondition: (data) => data['success'] == true,
defaultError: "Failed to send reset link."); defaultError: "Failed to send reset link.");
static Future<Map<String, String>?> requestDemo( /// Generates an MPIN for the user.
Map<String, dynamic> demoData) =>
_wrapErrorHandling(() => _post("/market/inquiry", demoData),
successCondition: (data) => data['success'] == true,
defaultError: "Failed to submit demo request.");
static Future<List<Map<String, dynamic>>?> getIndustries() async {
final data = await _get("/market/industries");
if (data != null && data['success'] == true) {
return List<Map<String, dynamic>>.from(data['data']);
}
return null;
}
static Future<Map<String, String>?> generateMpin({ static Future<Map<String, String>?> generateMpin({
required String employeeId, required String employeeId,
required String mpin, required String mpin,
}) => }) =>
_wrapErrorHandling( _wrapErrorHandling(
() async { () async {
final token = LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
return _post( return _networkRequest(
"/auth/generate-mpin", path: "/auth/generate-mpin",
{"employeeId": employeeId, "mpin": mpin}, method: _HttpMethod.post,
body: {"employeeId": employeeId, "mpin": mpin},
authToken: token, authToken: token,
); );
}, },
@ -164,6 +172,7 @@ class AuthService {
defaultError: "Failed to generate MPIN.", defaultError: "Failed to generate MPIN.",
); );
/// Verifies the MPIN for quick login.
static Future<Map<String, String>?> verifyMpin({ static Future<Map<String, String>?> verifyMpin({
required String mpin, required String mpin,
required String mpinToken, required String mpinToken,
@ -171,12 +180,15 @@ class AuthService {
}) => }) =>
_wrapErrorHandling( _wrapErrorHandling(
() async { () async {
final employeeInfo = LocalStorage.getEmployeeInfo(); final employeeInfo = await LocalStorage.getEmployeeInfo();
if (employeeInfo == null) return null; if (employeeInfo == null)
return null; // Fails immediately if info is missing
final token = await LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
return _post(
"/auth/login-mpin", final responseData = await _networkRequest(
{ path: "/auth/login-mpin",
method: _HttpMethod.post,
body: {
"employeeId": employeeInfo.id, "employeeId": employeeInfo.id,
"mpin": mpin, "mpin": mpin,
"mpinToken": mpinToken, "mpinToken": mpinToken,
@ -184,21 +196,41 @@ class AuthService {
}, },
authToken: token, authToken: token,
); );
// Handle token updates from MPIN login success if necessary,
// though typically refresh or a separate login handles this.
if (responseData?['data'] != null) {
await _handleLoginSuccess(responseData!['data']);
}
return responseData;
}, },
successCondition: (data) => data['success'] == true, successCondition: (data) => data['success'] == true,
defaultError: "MPIN verification failed.", defaultError: "MPIN verification failed.",
); );
/// Generates an OTP for login/verification.
static Future<Map<String, String>?> generateOtp(String email) => static Future<Map<String, String>?> generateOtp(String email) =>
_wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}), _wrapErrorHandling(
() => _networkRequest(
path: "/auth/send-otp",
method: _HttpMethod.post,
body: {"email": email},
),
successCondition: (data) => data['success'] == true, successCondition: (data) => data['success'] == true,
defaultError: "Failed to generate OTP."); defaultError: "Failed to generate OTP.");
/// Verifies the OTP and completes the login process.
static Future<Map<String, String>?> verifyOtp({ static Future<Map<String, String>?> verifyOtp({
required String email, required String email,
required String otp, required String otp,
}) async { }) async {
final data = await _post("/auth/login-otp", {"email": email, "otp": otp}); final data = await _networkRequest(
path: "/auth/login-otp",
method: _HttpMethod.post,
body: {"email": email, "otp": otp},
);
if (data != null && data['data'] != null) { if (data != null && data['data'] != null) {
await _handleLoginSuccess(data['data']); await _handleLoginSuccess(data['data']);
return null; return null;
@ -206,55 +238,309 @@ class AuthService {
return {"error": data?['message'] ?? "OTP verification failed."}; return {"error": data?['message'] ?? "OTP verification failed."};
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* Private Utilities */ /* MARKET/OTHER METHODS                          */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
static Future<Map<String, dynamic>?> _post( /// Submits a demo request to the market endpoint.
String path, static Future<Map<String, String>?> requestDemo(
Map<String, dynamic> body, { Map<String, dynamic> demoData) =>
_wrapErrorHandling(
() => _networkRequest(
path: "/market/inquiry",
method: _HttpMethod.post,
body: demoData,
),
successCondition: (data) => data['success'] == true,
defaultError: "Failed to submit demo request.");
/// Fetches the list of available industries.
static Future<List<Map<String, dynamic>>?> getIndustries() async {
final data = await _networkRequest(
path: "/market/industries",
method: _HttpMethod.get,
);
if (data != null && data['success'] == true && data['data'] is List) {
return List<Map<String, dynamic>>.from(data['data']);
}
return null;
}
/* -------------------------------------------------------------------------- */
/* TENANT METHODS                                */
/* -------------------------------------------------------------------------- */
static void setSelectedTenant(Tenant tenant) {
currentTenant = tenant;
}
static bool get isTenantSelected => currentTenant != null;
/// Fetches the list of tenants the user belongs to.
static Future<List<Map<String, dynamic>>?> getTenants(
{bool hasRetried = false}) async {
final token = await LocalStorage.getJwtToken();
if (token == null) {
await _handleUnauthorized();
return null;
}
final data = await _networkRequest(
path: "/auth/get/user/tenants",
method: _HttpMethod.get,
authToken: token,
);
if (data != null && data['success'] == true && data['data'] is List) {
return List<Map<String, dynamic>>.from(data['data']);
}
// Handle 401 Unauthorized via refreshToken/retry logic
if (data?['statusCode'] == 401 && !hasRetried) {
final refreshed = await refreshToken();
if (refreshed) return getTenants(hasRetried: true);
}
// Fallback on all other failures
if (data != null && data['statusCode'] != 401) {
_handleApiError(data['statusCode'], data, "Fetching tenants");
} else if (data?['statusCode'] == 401 && hasRetried) {
await _handleUnauthorized();
}
return null;
}
/// Selects a specific tenant, updating the JWT and refresh tokens.
static Future<bool> selectTenant(String tenantId,
{bool hasRetried = false}) async {
final token = await LocalStorage.getJwtToken();
if (token == null) {
await _handleUnauthorized();
return false;
}
final data = await _networkRequest(
path: "/auth/select-tenant/$tenantId",
method: _HttpMethod.post,
authToken: token,
);
if (data != null && data['success'] == true && data['data'] != null) {
await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
logSafe("✅ Tenant selected successfully. Tokens updated.");
// Refresh project controller data
try {
final projectController = Get.find<ProjectController>();
projectController.clearProjects();
projectController.fetchProjects();
} catch (_) {
logSafe("⚠️ ProjectController not found while refreshing projects");
}
// Re-register FCM token with new tenant context
final fcmToken = await LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
await registerDeviceToken(fcmToken!);
}
return true;
}
// Handle 401 Unauthorized via refreshToken/retry logic
if (data?['statusCode'] == 401 && !hasRetried) {
final refreshed = await refreshToken();
if (refreshed) return selectTenant(tenantId, hasRetried: true);
await _handleUnauthorized();
}
// Fallback on all other failures
if (data != null) {
_handleApiError(data['statusCode'], data, "Selecting tenant");
}
return false;
}
/* -------------------------------------------------------------------------- */
/* PERMISSION/USER METHODS                        */
/* -------------------------------------------------------------------------- */
/// Fetches all user-related data (permissions, employee info, projects).
static Future<Map<String, dynamic>> fetchAllUserData(
String token, {
bool hasRetried = false,
}) async {
logSafe("Fetching user data...");
final cached = _userDataCache[token];
if (cached != null) {
logSafe("User data cache hit.");
return cached;
}
final data = await _networkRequest(
path: "/user/profile",
method: _HttpMethod.get,
authToken: token,
);
if (data != null &&
data['success'] == true &&
data['data'] is Map<String, dynamic>) {
final responseData = data['data'] as Map<String, dynamic>;
final result = {
'permissions': _parsePermissions(responseData['featurePermissions']),
'employeeInfo': await _parseEmployeeInfo(responseData['employeeInfo']),
'projects': _parseProjectsInfo(responseData['projects']),
};
_userDataCache[token] = result;
logSafe("User data fetched and decrypted successfully.");
return result;
}
// Handle 401 Unauthorized via refreshToken/retry logic
if (data?['statusCode'] == 401 && !hasRetried) {
final refreshed = await refreshToken();
final newToken = await LocalStorage.getJwtToken();
if (refreshed && newToken != null) {
return fetchAllUserData(newToken, hasRetried: true);
}
}
// Handle failure and unauthorized
if (data?['statusCode'] == 401 ||
data?['statusCode'] == 403 ||
data == null) {
await _handleUnauthorized();
throw Exception('Unauthorized or Network Error. Token refresh failed.');
}
final errorMsg = data['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $errorMsg');
}
/* -------------------------------------------------------------------------- */
/* Private Utilities                              */
/* -------------------------------------------------------------------------- */
/// Global handler for unauthorized access, clears tokens and redirects.
static Future<void> _handleUnauthorized() async {
logSafe(
"Clearing tokens and redirecting to login due to unauthorized access.",
level: LogLevel.warning);
await LocalStorage.removeToken('jwt_token');
await LocalStorage.removeToken('refresh_token');
await LocalStorage.setLoggedInUser(false);
isLoggedIn = false;
Get.offAllNamed('/auth/login-option');
}
/// Parses raw permission list into a list of UserPermission models.
static List<UserPermission> _parsePermissions(List<dynamic>? permissions) {
logSafe("Parsing user permissions...");
if (permissions == null) return [];
return permissions
.map((perm) => UserPermission.fromJson({'id': perm}))
.toList();
}
/// Parses raw employee info, stores it locally, and returns the model.
static Future<EmployeeInfo> _parseEmployeeInfo(
Map<String, dynamic>? data) async {
logSafe("Parsing employee info...");
if (data == null) throw Exception("Employee data missing");
final employeeInfo = EmployeeInfo.fromJson(data);
await LocalStorage.setEmployeeInfo(employeeInfo);
return employeeInfo;
}
/// Parses raw projects list into a list of ProjectInfo models.
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
logSafe("Parsing projects info...");
if (projects == null) return [];
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
}
/// Internal utility to report API errors.
static void _handleApiError(
int statusCode, Map<String, dynamic> data, String context) {
final message = data['message'] ?? 'Unknown error';
final level = statusCode >= 500 ? LogLevel.error : LogLevel.warning;
logSafe("$context failed: $message [Status: $statusCode]", level: level);
}
/// General network request handler for both GET and POST.
static Future<Map<String, dynamic>?> _networkRequest({
required String path,
required _HttpMethod method,
Map<String, dynamic>? body,
String? authToken, String? authToken,
}) async { }) async {
final uri = Uri.parse("$_baseUrl$path");
final headers = {
..._defaultHeaders,
if (authToken?.isNotEmpty ?? false) 'Authorization': 'Bearer $authToken',
};
http.Response? response;
try { try {
final headers = { logSafe(
..._headers, "➡️ ${method.name.toUpperCase()} $_baseUrl$path${body != null ? '\nBody: ${jsonEncode(body)}' : ''}",
if (authToken?.isNotEmpty ?? false) level: LogLevel.info);
'Authorization': 'Bearer $authToken',
}; if (method == _HttpMethod.post) {
final response = await http.post(Uri.parse("$_baseUrl$path"), response =
headers: headers, body: jsonEncode(body)); await http.post(uri, headers: headers, body: jsonEncode(body));
return { } else {
...jsonDecode(response.body), // GET
"statusCode": response.statusCode, response = await http.get(uri, headers: headers);
}; }
if (response.body.isEmpty || response.body.trim().isEmpty) {
logSafe("❌ Empty response for $path", level: LogLevel.error);
// Special case for unauthorized response with no body (e.g., gateway issue)
if (response.statusCode == 401) {
await _handleUnauthorized();
}
return {
"statusCode": response.statusCode,
"success": false,
"message": "Empty response body"
};
}
final decrypted = decryptResponse(response.body);
if (decrypted == null) {
logSafe("❌ Response decryption failed for $path",
level: LogLevel.error);
return {
"statusCode": response.statusCode,
"success": false,
"message": "Failed to decrypt response"
};
}
final Map<String, dynamic> result = decrypted is Map<String, dynamic>
? decrypted
: {"data": decrypted}; // Wrap non-map responses
logSafe(
"⬅️ Response: ${jsonEncode(result)} [Status: ${response.statusCode}]",
level: LogLevel.info);
return {"statusCode": response.statusCode, ...result};
} catch (e, st) { } catch (e, st) {
_handleError("$path POST error", e, st); _handleError("$path ${method.name.toUpperCase()} error", e, st);
return null;
}
}
static Future<Map<String, dynamic>?> _get(
String path, {
String? authToken,
}) async {
try {
final headers = {
..._headers,
if (authToken?.isNotEmpty ?? false)
'Authorization': 'Bearer $authToken',
};
final response =
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
return {
...jsonDecode(response.body),
"statusCode": response.statusCode,
};
} catch (e, st) {
_handleError("$path GET error", e, st);
return null; return null;
} }
} }
/// Utility to wrap simple API calls with error-to-UI message mapping.
static Future<Map<String, String>?> _wrapErrorHandling( static Future<Map<String, String>?> _wrapErrorHandling(
Future<Map<String, dynamic>?> Function() request, { Future<Map<String, dynamic>?> Function() request, {
required bool Function(Map<String, dynamic> data) successCondition, required bool Function(Map<String, dynamic> data) successCondition,
@ -265,13 +551,13 @@ class AuthService {
return {"error": data?['message'] ?? defaultError}; return {"error": data?['message'] ?? defaultError};
} }
/// Generic error logging helper.
static void _handleError(String message, Object error, StackTrace st) { static void _handleError(String message, Object error, StackTrace st) {
logSafe(message, level: LogLevel.error, error: error, stackTrace: st); logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
} }
/// Common logic for storing tokens and login state upon successful authentication.
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async { static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
logSafe("Processing login success...");
await LocalStorage.setJwtToken(data['token']); await LocalStorage.setJwtToken(data['token']);
await LocalStorage.setLoggedInUser(true); await LocalStorage.setLoggedInUser(true);
@ -287,6 +573,5 @@ class AuthService {
await LocalStorage.removeMpinToken(); await LocalStorage.removeMpinToken();
} }
isLoggedIn = true; isLoggedIn = true;
logSafe("✅ Login flow completed and controllers initialized.");
} }
} }

View File

@ -0,0 +1,255 @@
// lib/helpers/services/http_client.dart
import 'dart:convert';
import 'dart:io';
import 'package:http/http.dart' as http;
import 'package:jwt_decoder/jwt_decoder.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/utils/encryption_helper.dart';
/// Centralized HTTP client with automatic token management, encryption,
/// and retry logic for OnFieldWork.com API communication.
class HttpClient {
static const Duration _timeout = Duration(seconds: 60);
static const Duration _tokenRefreshThreshold = Duration(minutes: 2);
final http.Client _client = http.Client();
bool _isRefreshing = false;
/// Private constructor - use singleton instance
HttpClient._();
static final HttpClient instance = HttpClient._();
/// Clean headers with JWT token
Map<String, String> _defaultHeaders(String token) => {
'Content-Type': 'application/json',
'Authorization': 'Bearer $token',
};
/// Ensures valid token with proactive refresh
Future<String?> _getValidToken() async {
String? token = await LocalStorage.getJwtToken();
if (token == null) {
logSafe("No JWT token available", level: LogLevel.error);
await LocalStorage.logout();
return null;
}
try {
if (JwtDecoder.isExpired(token) ||
JwtDecoder.getExpirationDate(token).difference(DateTime.now()) <
_tokenRefreshThreshold) {
logSafe("Token expired/expiring soon. Refreshing...",
level: LogLevel.info);
if (!await _refreshTokenIfPossible()) {
logSafe("Token refresh failed. Logging out.", level: LogLevel.error);
await LocalStorage.logout();
return null;
}
token = await LocalStorage.getJwtToken();
}
} catch (e) {
logSafe("Token validation failed: $e. Logging out.",
level: LogLevel.error);
await LocalStorage.logout();
return null;
}
return token;
}
/// Attempts token refresh with concurrency protection
Future<bool> _refreshTokenIfPossible() async {
if (_isRefreshing) return false;
_isRefreshing = true;
try {
return await AuthService.refreshToken();
} finally {
_isRefreshing = false;
}
}
/// Unified response parser with decryption and validation
dynamic _parseResponse(
http.Response response, {
required String endpoint,
bool fullResponse = false,
}) {
final body = response.body.trim();
if (body.isEmpty &&
response.statusCode >= 200 &&
response.statusCode < 300) {
logSafe("Empty response for $endpoint - returning default structure",
level: LogLevel.info);
return fullResponse ? {'success': true, 'data': []} : [];
}
final decryptedData = decryptResponse(body);
if (decryptedData == null) {
logSafe("❌ Decryption failed for $endpoint", level: LogLevel.error);
return null;
}
final jsonData = decryptedData;
if (response.statusCode >= 200 && response.statusCode < 300) {
if (jsonData is Map && jsonData['success'] == true) {
logSafe("$endpoint: Success (${response.statusCode})",
level: LogLevel.info);
return fullResponse ? jsonData : jsonData['data'];
} else if (jsonData is Map) {
logSafe(
"⚠️ $endpoint: API error - ${jsonData['message'] ?? 'Unknown error'}",
level: LogLevel.warning);
}
}
logSafe("$endpoint: HTTP ${response.statusCode} - $jsonData",
level: LogLevel.error);
return null;
}
/// Generic request executor with 401 retry logic
Future<http.Response?> _execute(
String method,
String endpoint, {
Map<String, String>? queryParams,
Object? body,
Map<String, String>? extraHeaders,
bool hasRetried = false,
}) async {
final token = await _getValidToken();
if (token == null) return null;
final uri = Uri.parse("${ApiEndpoints.baseUrl}$endpoint").replace(
queryParameters:
(method == 'GET' || method == 'DELETE') ? queryParams : null);
final headers = {
..._defaultHeaders(token),
if (extraHeaders != null) ...extraHeaders,
};
final requestBody = body != null ? jsonEncode(body) : null;
logSafe(
"📡 $method $uri${requestBody != null ? ' | Body: ${requestBody.length > 100 ? '${requestBody.substring(0, 100)}...' : requestBody}' : ''}",
level: LogLevel.debug);
try {
final response = switch (method) {
'GET' => await _client.get(uri, headers: headers).timeout(_timeout),
'POST' => await _client
.post(uri, headers: headers, body: requestBody)
.timeout(_timeout),
'PUT' => await _client
.put(uri, headers: headers, body: requestBody)
.timeout(_timeout),
'PATCH' => await _client
.patch(uri, headers: headers, body: requestBody)
.timeout(_timeout),
'DELETE' =>
await _client.delete(uri, headers: headers).timeout(_timeout),
_ => throw HttpException('Unsupported method: $method'),
};
// Handle 401 with single retry
if (response.statusCode == 401 && !hasRetried) {
logSafe("🔄 401 detected for $endpoint - retrying with fresh token",
level: LogLevel.warning);
if (await _refreshTokenIfPossible()) {
return await _execute(method, endpoint,
queryParams: queryParams,
body: body,
extraHeaders: extraHeaders,
hasRetried: true);
}
await LocalStorage.logout();
return null;
}
return response;
} on SocketException catch (e) {
logSafe("🌐 Network error for $endpoint: $e", level: LogLevel.error);
return null;
} catch (e, stackTrace) {
logSafe("💥 HTTP $method error for $endpoint: $e\n$stackTrace",
level: LogLevel.error);
return null;
}
}
// Public API - Clean and consistent
Future<T?> get<T>(
String endpoint, {
Map<String, String>? queryParams,
bool fullResponse = false,
}) async {
final response = await _execute('GET', endpoint, queryParams: queryParams);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
Future<T?> post<T>(
String endpoint,
Object? body, {
bool fullResponse = false,
}) async {
final response = await _execute('POST', endpoint, body: body);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
Future<T?> put<T>(
String endpoint,
Object? body, {
Map<String, String>? extraHeaders,
bool fullResponse = false,
}) async {
final response =
await _execute('PUT', endpoint, body: body, extraHeaders: extraHeaders);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
Future<T?> patch<T>(
String endpoint,
Object? body, {
bool fullResponse = false,
}) async {
final response = await _execute('PATCH', endpoint, body: body);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
Future<T?> delete<T>(
String endpoint, {
Map<String, String>? queryParams,
bool fullResponse = false,
}) async {
final response =
await _execute('DELETE', endpoint, queryParams: queryParams);
return response != null
? _parseResponse(response,
endpoint: endpoint, fullResponse: fullResponse)
: null;
}
/// Proper cleanup for long-lived instances
void dispose() {
_client.close();
}
}

View File

@ -1,111 +0,0 @@
import 'dart:convert';
import 'package:get/get.dart';
import 'package:http/http.dart' as http;
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/model/user_permission.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/model/projects_model.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart';
class PermissionService {
// In-memory cache keyed by user token
static final Map<String, Map<String, dynamic>> _userDataCache = {};
static const String _baseUrl = ApiEndpoints.baseUrl;
/// Fetches all user-related data (permissions, employee info, projects).
/// Uses in-memory cache for repeated token queries during session.
static Future<Map<String, dynamic>> fetchAllUserData(
String token, {
bool hasRetried = false,
}) async {
logSafe("Fetching user data...");
// Check for cached data before network request
final cached = _userDataCache[token];
if (cached != null) {
logSafe("User data cache hit.");
return cached;
}
final uri = Uri.parse("$_baseUrl/user/profile");
final headers = {'Authorization': 'Bearer $token'};
try {
final response = await http.get(uri, headers: headers);
final statusCode = response.statusCode;
if (statusCode == 200) {
final raw = json.decode(response.body);
final data = raw['data'] as Map<String, dynamic>;
final result = {
'permissions': _parsePermissions(data['featurePermissions']),
'employeeInfo': _parseEmployeeInfo(data['employeeInfo']),
'projects': _parseProjectsInfo(data['projects']),
};
_userDataCache[token] = result; // Cache it for future use
logSafe("User data fetched successfully.");
return result;
}
// Token expired, try refresh once then redirect on failure
if (statusCode == 401 && !hasRetried) {
logSafe("Unauthorized. Attempting token refresh...", level: LogLevel.warning);
final refreshed = await AuthService.refreshToken();
if (refreshed) {
final newToken = await LocalStorage.getJwtToken();
if (newToken != null && newToken.isNotEmpty) {
return fetchAllUserData(newToken, hasRetried: true);
}
}
await _handleUnauthorized();
logSafe("Token refresh failed. Redirecting to login.", level: LogLevel.warning);
throw Exception('Unauthorized. Token refresh failed.');
}
final errorMsg = json.decode(response.body)['message'] ?? 'Unknown error';
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
throw Exception('Failed to fetch user data: $errorMsg');
} catch (e, stacktrace) {
logSafe("Exception while fetching user data", level: LogLevel.error, error: e, stackTrace: stacktrace);
rethrow; // Let the caller handle or report
}
}
/// Handles unauthorized/user sign out flow
static Future<void> _handleUnauthorized() async {
logSafe("Clearing tokens and redirecting to login due to unauthorized access.", level: LogLevel.warning);
await LocalStorage.removeToken('jwt_token');
await LocalStorage.removeToken('refresh_token');
await LocalStorage.setLoggedInUser(false);
Get.offAllNamed('/auth/login-option');
}
/// Robust model parsing for permissions
static List<UserPermission> _parsePermissions(List<dynamic> permissions) {
logSafe("Parsing user permissions...");
return permissions
.map((perm) => UserPermission.fromJson({'id': perm}))
.toList();
}
/// Robust model parsing for employee info
static EmployeeInfo _parseEmployeeInfo(Map<String, dynamic>? data) {
logSafe("Parsing employee info...");
if (data == null) throw Exception("Employee data missing");
return EmployeeInfo.fromJson(data);
}
/// Robust model parsing for projects list
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
logSafe("Parsing projects info...");
if (projects == null) return [];
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
}
}

View File

@ -2,7 +2,6 @@ import 'dart:convert';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/localizations/language.dart'; import 'package:on_field_work/helpers/services/localizations/language.dart';
import 'package:on_field_work/helpers/theme/theme_customizer.dart'; import 'package:on_field_work/helpers/theme/theme_customizer.dart';
@ -139,6 +138,7 @@ class LocalStorage {
print("Logout API error: $e"); print("Logout API error: $e");
} }
// Remove all stored values
await removeLoggedInUser(); await removeLoggedInUser();
await removeToken(_jwtTokenKey); await removeToken(_jwtTokenKey);
await removeToken(_refreshTokenKey); await removeToken(_refreshTokenKey);
@ -147,16 +147,15 @@ class LocalStorage {
await removeMpinToken(); await removeMpinToken();
await removeIsMpin(); await removeIsMpin();
await removeMenus(); await removeMenus();
await removeRecentTenantId(); await removeRecentTenantId();
await preferences.remove("mpin_verified"); await preferences.remove("mpin_verified");
await preferences.remove(_languageKey); await preferences.remove(_languageKey);
await preferences.remove(_themeCustomizerKey); await preferences.remove(_themeCustomizerKey);
await preferences.remove('selectedProjectId'); await preferences.remove('selectedProjectId');
if (Get.isRegistered<ProjectController>()) { // Clear all GetX controllers
Get.find<ProjectController>().clearProjects(); Get.deleteAll(force: true);
} // Navigate to login
Get.offAllNamed('/auth/login-option'); Get.offAllNamed('/auth/login-option');
} }

View File

@ -1,173 +0,0 @@
import 'dart:convert';
import 'package:http/http.dart' as http;
import 'package:get/get.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
/// Abstract interface for tenant service functionality
abstract class ITenantService {
Future<List<Map<String, dynamic>>?> getTenants({bool hasRetried = false});
Future<bool> selectTenant(String tenantId, {bool hasRetried = false});
}
/// Tenant API service
class TenantService implements ITenantService {
static const String _baseUrl = ApiEndpoints.baseUrl;
static const Map<String, String> _headers = {
'Content-Type': 'application/json',
};
/// Currently selected tenant
static Tenant? currentTenant;
/// Set the selected tenant
static void setSelectedTenant(Tenant tenant) {
currentTenant = tenant;
}
/// Check if tenant is selected
static bool get isTenantSelected => currentTenant != null;
/// Build authorized headers
static Future<Map<String, String>> _authorizedHeaders() async {
final token = await LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
throw Exception('Missing JWT token');
}
return {..._headers, 'Authorization': 'Bearer $token'};
}
/// Handle API errors
static void _handleApiError(
http.Response response, dynamic data, String context) {
final message = data['message'] ?? 'Unknown error';
final level =
response.statusCode >= 500 ? LogLevel.error : LogLevel.warning;
logSafe("$context failed: $message [Status: ${response.statusCode}]",
level: level);
}
/// Log exceptions
static void _logException(dynamic e, dynamic st, String context) {
logSafe("$context exception",
level: LogLevel.error, error: e, stackTrace: st);
}
@override
Future<List<Map<String, dynamic>>?> getTenants(
{bool hasRetried = false}) async {
try {
final headers = await _authorizedHeaders();
final response = await http.get(
Uri.parse("$_baseUrl/auth/get/user/tenants"),
headers: headers,
);
// Handle empty response BEFORE decoding
if (response.body.isEmpty || response.body.trim().isEmpty) {
logSafe("❌ Empty tenant response — auto logout");
await LocalStorage.logout();
return null;
}
Map<String, dynamic> data;
try {
data = jsonDecode(response.body);
} catch (e) {
logSafe("❌ Invalid JSON in tenant response — auto logout");
await LocalStorage.logout();
return null;
}
// SUCCESS CASE
if (response.statusCode == 200 && data['success'] == true) {
final list = data['data'];
if (list is! List) return null;
return List<Map<String, dynamic>>.from(list);
}
// TOKEN EXPIRED
if (response.statusCode == 401 && !hasRetried) {
final refreshed = await AuthService.refreshToken();
if (refreshed) return getTenants(hasRetried: true);
return null;
}
_handleApiError(response, data, "Fetching tenants");
return null;
} catch (e, st) {
_logException(e, st, "Get Tenants API");
return null;
}
}
@override
Future<bool> selectTenant(String tenantId, {bool hasRetried = false}) async {
try {
final headers = await _authorizedHeaders();
logSafe(
"➡️ POST $_baseUrl/auth/select-tenant/$tenantId\nHeaders: $headers",
level: LogLevel.info);
final response = await http.post(
Uri.parse("$_baseUrl/auth/select-tenant/$tenantId"),
headers: headers,
);
final data = jsonDecode(response.body);
logSafe(
"⬅️ Response: ${jsonEncode(data)} [Status: ${response.statusCode}]",
level: LogLevel.info);
if (response.statusCode == 200 && data['success'] == true) {
await LocalStorage.setJwtToken(data['data']['token']);
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
logSafe("✅ Tenant selected successfully. Tokens updated.");
// 🔥 Refresh projects when tenant changes
try {
final projectController = Get.find<ProjectController>();
projectController.clearProjects();
projectController.fetchProjects();
} catch (_) {
logSafe("⚠️ ProjectController not found while refreshing projects");
}
// 🔹 Register FCM token after tenant selection
final fcmToken = LocalStorage.getFcmToken();
if (fcmToken?.isNotEmpty ?? false) {
final success = await AuthService.registerDeviceToken(fcmToken!);
logSafe(
success
? "✅ FCM token registered after tenant selection."
: "⚠️ Failed to register FCM token after tenant selection.",
level: success ? LogLevel.info : LogLevel.warning);
}
return true;
}
if (response.statusCode == 401 && !hasRetried) {
logSafe("⚠️ Unauthorized while selecting tenant. Refreshing token...",
level: LogLevel.warning);
final refreshed = await AuthService.refreshToken();
if (refreshed) return selectTenant(tenantId, hasRetried: true);
logSafe("❌ Token refresh failed while selecting tenant.",
level: LogLevel.error);
return false;
}
_handleApiError(response, data, "Selecting tenant");
return false;
} catch (e, st) {
_logException(e, st, "Select Tenant API");
return false;
}
}
}

View File

@ -25,7 +25,8 @@ int get flexColumns => MyScreenMedia.flexColumns;
class MaterialRadius { class MaterialRadius {
double xs, small, medium, large; double xs, small, medium, large;
MaterialRadius({this.xs = 2, this.small = 4, this.medium = 6, this.large = 8}); MaterialRadius(
{this.xs = 2, this.small = 4, this.medium = 6, this.large = 8});
} }
class ColorGroup { class ColorGroup {
@ -41,10 +42,12 @@ class AppTheme {
static Color primaryColor = Color(0xff663399); static Color primaryColor = Color(0xff663399);
static ThemeData getThemeFromThemeMode() { static ThemeData getThemeFromThemeMode() {
return ThemeCustomizer.instance.theme == ThemeMode.light ? lightTheme : darkTheme; return ThemeCustomizer.instance.theme == ThemeMode.light
? lightTheme
: darkTheme;
} }
/// -------------------------- Light Theme -------------------------------------------- /// /// -------------------------- Light Theme  -------------------------------------------- ///
static final ThemeData lightTheme = ThemeData( static final ThemeData lightTheme = ThemeData(
/// Brightness /// Brightness
@ -60,14 +63,18 @@ class AppTheme {
/// AppBar Theme /// AppBar Theme
appBarTheme: AppBarTheme( appBarTheme: AppBarTheme(
backgroundColor: Color(0xffF5F5F5), iconTheme: IconThemeData(color: Color(0xff495057)), actionsIconTheme: IconThemeData(color: Color(0xff495057))), backgroundColor: Color(0xffF5F5F5),
iconTheme: IconThemeData(color: Color(0xff495057)),
actionsIconTheme: IconThemeData(color: Color(0xff495057))),
/// Card Theme /// Card Theme
cardTheme: CardTheme(color: Color(0xffffffff)), // FIX: Use CardThemeData
cardTheme: CardThemeData(color: Color(0xffffffff)),
cardColor: Color(0xffffffff), cardColor: Color(0xffffffff),
/// Colorscheme /// Colorscheme
colorScheme: ColorScheme.fromSeed(seedColor: Color(0xff663399), brightness: Brightness.light), colorScheme: ColorScheme.fromSeed(
seedColor: Color(0xff663399), brightness: Brightness.light),
snackBarTheme: SnackBarThemeData(actionTextColor: Colors.white), snackBarTheme: SnackBarThemeData(actionTextColor: Colors.white),
@ -86,10 +93,12 @@ class AppTheme {
dividerColor: Color(0xffdddddd), dividerColor: Color(0xffdddddd),
/// Bottom AppBar Theme /// Bottom AppBar Theme
bottomAppBarTheme: BottomAppBarTheme(color: Color(0xffeeeeee), elevation: 2), // FIX: Use BottomAppBarThemeData
bottomAppBarTheme:
BottomAppBarThemeData(color: Color(0xffeeeeee), elevation: 2),
/// Tab bar Theme /// Tab bar Theme
tabBarTheme: TabBarTheme( tabBarTheme: TabBarThemeData(
unselectedLabelColor: Color(0xff495057), unselectedLabelColor: Color(0xff495057),
labelColor: AppTheme.primaryColor, labelColor: AppTheme.primaryColor,
indicatorSize: TabBarIndicatorSize.label, indicatorSize: TabBarIndicatorSize.label,
@ -123,8 +132,11 @@ class AppTheme {
checkColor: WidgetStateProperty.all(Color(0xffffffff)), checkColor: WidgetStateProperty.all(Color(0xffffffff)),
fillColor: WidgetStateProperty.all(AppTheme.primaryColor), fillColor: WidgetStateProperty.all(AppTheme.primaryColor),
), ),
switchTheme: switchTheme: SwitchThemeData(
SwitchThemeData(thumbColor: WidgetStateProperty.resolveWith((states) => states.contains(WidgetState.selected) ? AppTheme.primaryColor : Colors.white)), thumbColor: WidgetStateProperty.resolveWith((states) =>
states.contains(WidgetState.selected)
? AppTheme.primaryColor
: Colors.white)),
/// Other Colors /// Other Colors
splashColor: Colors.white.withAlpha(100), splashColor: Colors.white.withAlpha(100),
@ -132,8 +144,9 @@ class AppTheme {
highlightColor: Color(0xffeeeeee), highlightColor: Color(0xffeeeeee),
); );
/// -------------------------- Dark Theme -------------------------------------------- /// /// -------------------------- Dark Theme  -------------------------------------------- ///
static final ThemeData darkTheme = ThemeData.dark(useMaterial3: false).copyWith( static final ThemeData darkTheme =
ThemeData.dark(useMaterial3: false).copyWith(
/// Brightness /// Brightness
/// Scaffold and Background color /// Scaffold and Background color
@ -146,7 +159,8 @@ class AppTheme {
appBarTheme: AppBarTheme(backgroundColor: Color(0xff262729)), appBarTheme: AppBarTheme(backgroundColor: Color(0xff262729)),
/// Card Theme /// Card Theme
cardTheme: CardTheme(color: Color(0xff1b1b1c)), // FIX: Use CardThemeData
cardTheme: CardThemeData(color: Color(0xff1b1b1c)),
cardColor: Color(0xff1b1b1c), cardColor: Color(0xff1b1b1c),
/// Colorscheme /// Colorscheme
@ -175,10 +189,13 @@ class AppTheme {
foregroundColor: Colors.white), foregroundColor: Colors.white),
/// Bottom AppBar Theme /// Bottom AppBar Theme
bottomAppBarTheme: BottomAppBarTheme(color: Color(0xff464c52), elevation: 2), // FIX: Use BottomAppBarThemeData
bottomAppBarTheme:
BottomAppBarThemeData(color: Color(0xff464c52), elevation: 2),
/// Tab bar Theme /// Tab bar Theme
tabBarTheme: TabBarTheme( // FIX: Use TabBarThemeData
tabBarTheme: TabBarThemeData(
unselectedLabelColor: Color(0xff495057), unselectedLabelColor: Color(0xff495057),
labelColor: AppTheme.primaryColor, labelColor: AppTheme.primaryColor,
indicatorSize: TabBarIndicatorSize.label, indicatorSize: TabBarIndicatorSize.label,
@ -230,7 +247,8 @@ class AppStyle {
containerRadius: AppStyle.containerRadius.medium, containerRadius: AppStyle.containerRadius.medium,
cardRadius: AppStyle.cardRadius.medium, cardRadius: AppStyle.cardRadius.medium,
buttonRadius: AppStyle.buttonRadius.medium, buttonRadius: AppStyle.buttonRadius.medium,
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'On Field Work', route: '/client/dashboard'), defaultBreadCrumbItem:
MyBreadcrumbItem(name: 'OnFieldWork.com', route: '/client/dashboard'),
)); ));
bool isMobile = true; bool isMobile = true;
try { try {
@ -241,12 +259,16 @@ class AppStyle {
My.setFlexSpacing(isMobile ? 16 : 24); My.setFlexSpacing(isMobile ? 16 : 24);
} }
/// -------------------------- Styles -------------------------------------------- /// /// -------------------------- Styles  -------------------------------------------- ///
static MaterialRadius buttonRadius = MaterialRadius(small: 2, medium: 4, large: 8); static MaterialRadius buttonRadius =
static MaterialRadius cardRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8); MaterialRadius(small: 2, medium: 4, large: 8);
static MaterialRadius containerRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8); static MaterialRadius cardRadius =
static MaterialRadius imageRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8); MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
static MaterialRadius containerRadius =
MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
static MaterialRadius imageRadius =
MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
} }
class AppColors { class AppColors {
@ -262,13 +284,16 @@ class AppColors {
static ColorGroup orange = ColorGroup(Color(0xffFFCEC2), Color(0xffFF3B0A)); static ColorGroup orange = ColorGroup(Color(0xffFFCEC2), Color(0xffFF3B0A));
static ColorGroup skyBlue = ColorGroup(Color(0xffC2F0FF), Color(0xff0099CC)); static ColorGroup skyBlue = ColorGroup(Color(0xffC2F0FF), Color(0xff0099CC));
static ColorGroup lavender = ColorGroup(Color(0xffEAE2F3), Color(0xff7748AD)); static ColorGroup lavender = ColorGroup(Color(0xffEAE2F3), Color(0xff7748AD));
static ColorGroup queenPink = ColorGroup(Color(0xffE8D9DC), Color(0xff804D57)); static ColorGroup queenPink =
static ColorGroup blueViolet = ColorGroup(Color(0xffC5C6E7), Color(0xff3B3E91)); ColorGroup(Color(0xffE8D9DC), Color(0xff804D57));
static ColorGroup blueViolet =
ColorGroup(Color(0xffC5C6E7), Color(0xff3B3E91));
static ColorGroup rosePink = ColorGroup(Color(0xffFCB1E0), Color(0xffEC0999)); static ColorGroup rosePink = ColorGroup(Color(0xffFCB1E0), Color(0xffEC0999));
static ColorGroup rubinRed = ColorGroup(Color(0x98f6a8bd), Color(0xffd03760)); static ColorGroup rubinRed = ColorGroup(Color(0x98f6a8bd), Color(0xffd03760));
static ColorGroup favorite = rubinRed; static ColorGroup favorite = rubinRed;
static ColorGroup redOrange = ColorGroup(Color(0xffFFAD99), Color(0xffF53100)); static ColorGroup redOrange =
ColorGroup(Color(0xffFFAD99), Color(0xffF53100));
static Color notificationSuccessBGColor = Color(0xff117E68); static Color notificationSuccessBGColor = Color(0xff117E68);
static Color notificationSuccessTextColor = Color(0xffffffff); static Color notificationSuccessTextColor = Color(0xffffffff);
@ -278,7 +303,16 @@ class AppColors {
static Color notificationErrorTextColor = Color(0xffFF3B0A); static Color notificationErrorTextColor = Color(0xffFF3B0A);
static Color notificationErrorActionColor = Color(0xff006784); static Color notificationErrorActionColor = Color(0xff006784);
static List<ColorGroup> list = [redOrange, violet, blue, green, orange, skyBlue, lavender, blueViolet]; static List<ColorGroup> list = [
redOrange,
violet,
blue,
green,
orange,
skyBlue,
lavender,
blueViolet
];
static ColorGroup get random => list[Random().nextInt(list.length)]; static ColorGroup get random => list[Random().nextInt(list.length)];
@ -287,7 +321,13 @@ class AppColors {
} }
static Color getColorByRating(int rating) { static Color getColorByRating(int rating) {
var colors = {1: Color(0xfff0323c), 2: Color(0xcdf0323c), 3: star, 4: Color(0xcd3cd278), 5: Color(0xff3cd278)}; var colors = {
1: Color(0xfff0323c),
2: Color(0xcdf0323c),
3: star,
4: Color(0xcd3cd278),
5: Color(0xff3cd278)
};
return colors[rating] ?? colors[1]!; return colors[rating] ?? colors[1]!;
} }

View File

@ -18,7 +18,12 @@ class ThemeOption {
final List<ThemeOption> themeOptions = [ final List<ThemeOption> themeOptions = [
ThemeOption( ThemeOption(
"Theme 1", Colors.red, Colors.red, Colors.red, ColorThemeType.red), "Theme 1",
const Color(0xFFC92226),
const Color(0xFFC92226),
const Color(0xFFC92226),
ColorThemeType.red,
),
ThemeOption( ThemeOption(
"Theme 2", "Theme 2",
const Color(0xFF49BF3C), const Color(0xFF49BF3C),

View File

@ -0,0 +1,75 @@
import 'dart:convert';
import 'package:encrypt/encrypt.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; // <-- for logging
// 🔑 CONSTANTS
// Base64-encoded 32-byte key (256 bits for AES-256)
const String _keyBase64 = "u4J7p9Qx2hF5vYtLz8Kq3mN1sG0bRwXyZcD6eH8jFQw=";
// IV must be 16 bytes for AES-CBC mode
const int _ivLength = 16;
/// Decrypts a Base64-encoded string that contains the IV prepended to the ciphertext.
/// Returns the decoded JSON object, the plain decrypted string, or null on failure.
dynamic decryptResponse(String encryptedBase64Str) {
try {
// 1 Initialize Key
final rawKeyBytes = base64.decode(_keyBase64);
if (rawKeyBytes.length != 32) {
logSafe("ERROR: Decoded key length is ${rawKeyBytes.length}. Expected 32 bytes for AES-256.", level: LogLevel.error);
throw Exception("Invalid key length.");
}
final key = Key(rawKeyBytes);
// 2 Decode incoming encrypted payload (IV + Ciphertext)
final fullBytes = base64.decode(encryptedBase64Str);
if (fullBytes.length < _ivLength + 16) {
// Minimum length check (16 bytes IV + 1 block of ciphertext, which is 16 bytes)
throw Exception("Encrypted string too short or corrupted.");
}
// 3 Extract IV & Ciphertext
// Assumes the first 16 bytes are the IV
final iv = IV(fullBytes.sublist(0, _ivLength));
final cipherTextBytes = fullBytes.sublist(_ivLength);
// 4 Configure Encrypter with specific parameters
// AES-256 with CBC mode and standard PKCS7 padding
final encrypter = Encrypter(
AES(
key,
mode: AESMode.cbc,
padding: 'PKCS7'
)
);
final encrypted = Encrypted(cipherTextBytes);
// 5 Decrypt - This is where the "Invalid or corrupted pad block" error occurs
final decryptedBytes = encrypter.decryptBytes(encrypted, iv: iv);
final decryptedString = utf8.decode(decryptedBytes);
if (decryptedString.isEmpty) {
throw Exception("Decryption produced empty string (check if padding was correct).");
}
// 🔹 Log decrypted snippet for verification
final snippetLength = decryptedString.length > 50 ? 50 : decryptedString.length;
logSafe(
"Decryption successful. Snippet: ${decryptedString.substring(0, snippetLength)}...",
level: LogLevel.info,
);
// 6 Try parsing JSON
try {
return jsonDecode(decryptedString);
} catch (_) {
// return plain string if it's not JSON
logSafe("Decrypted data is not JSON. Returning plain string.", level: LogLevel.warning);
return decryptedString;
}
} catch (e, st) {
// Catch the specific decryption error (e.g., 'Invalid or corrupted pad block')
logSafe("FATAL Decryption failed: $e", level: LogLevel.error, stackTrace: st);
return null;
}
}

View File

@ -5,11 +5,9 @@ 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_text.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class CustomAppBar extends StatefulWidget class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
with UIMixin
implements PreferredSizeWidget {
final String title; final String title;
final String? projectName; // If passed, show static text final String? projectName;
final VoidCallback? onBackPressed; final VoidCallback? onBackPressed;
final Color? backgroundColor; final Color? backgroundColor;
@ -51,13 +49,13 @@ class _CustomAppBarState extends State<CustomAppBar> with UIMixin {
return OverlayEntry( return OverlayEntry(
builder: (context) => GestureDetector( builder: (context) => GestureDetector(
onTap: () { onTap: () {
_toggleDropdown(); _toggleDropdown();
}, },
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
child: Stack( child: Stack(
children: [ children: [
Positioned( Positioned(
left: offset.dx + 16, left: offset.dx + 16,
top: offset.dy + size.height, top: offset.dy + size.height,
width: size.width - 32, width: size.width - 32,
child: Material( child: Material(

View File

@ -28,9 +28,7 @@ class CollectionsHealthWidget extends StatelessWidget {
return Container( return Container(
decoration: _boxDecoration(), decoration: _boxDecoration(),
padding: const EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: Center( child: const _EmptyDataWidget(), // <-- Use the new empty widget here
child: MyText.bodyMedium('No collection overview data available.'),
),
); );
} }
@ -287,6 +285,71 @@ class CollectionsHealthWidget extends StatelessWidget {
} }
} }
// =====================================================================
// NEW EMPTY DATA WIDGET FOR CollectionsHealthWidget
// =====================================================================
class _EmptyDataWidget extends StatelessWidget {
const _EmptyDataWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
// This height is set to resemble the expected height of the chart/metrics content
const double containerHeight = 220;
const double iconSize = 48;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Section
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),
],
),
),
],
),
// Empty Content Area
SizedBox(
height: containerHeight,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
color: Colors.grey.shade400,
size: iconSize,
),
const SizedBox(height: 10),
MyText.bodyMedium(
'No collection overview data available.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
MyText.bodySmall(
'Please check your data source or filters.',
textAlign: TextAlign.center,
color: Colors.grey.shade400,
),
],
),
),
),
],
);
}
}
// ===================================================================== // =====================================================================
// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars) // CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
// ===================================================================== // =====================================================================

View File

@ -12,19 +12,39 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
Widget build(BuildContext context) { Widget build(BuildContext context) {
final DashboardController controller = Get.find(); final DashboardController controller = Get.find();
// Define the common box decoration for the main card structure
final BoxDecoration cardDecoration = BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
);
// Use Obx to reactively listen to data changes // Use Obx to reactively listen to data changes
return Obx(() { return Obx(() {
final data = controller.purchaseInvoiceOverviewData.value; final data = controller.purchaseInvoiceOverviewData.value;
// Show loading state while API call is in progress // Show loading state while API call is in progress
if (controller.isPurchaseInvoiceLoading.value) { if (controller.isPurchaseInvoiceLoading.value) {
return SkeletonLoaders.purchaseInvoiceDashboardSkeleton(); return Container(
decoration: cardDecoration, // Apply decoration to loading state
padding: const EdgeInsets.all(16.0),
child: SkeletonLoaders.purchaseInvoiceDashboardSkeleton(),
);
} }
// Show empty state if no data // Show empty state if no data
if (data == null || data.totalInvoices == 0) { if (data == null || data.totalInvoices == 0) {
return Center( return Container(
child: MyText.bodySmall('No purchase invoices found.'), decoration: cardDecoration, // Apply decoration to empty state
padding: const EdgeInsets.all(16.0),
child: const _EmptyDataWidget(), // <-- Use the new empty widget
); );
} }
@ -42,27 +62,17 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices); final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
return _buildDashboard(metrics); return _buildDashboard(metrics, cardDecoration);
}); });
} }
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) { Widget _buildDashboard(PurchaseInvoiceMetrics metrics, BoxDecoration decoration) {
const double spacing = 16.0; const double spacing = 16.0;
const double smallSpacing = 8.0; const double smallSpacing = 8.0;
return Container( return Container(
padding: const EdgeInsets.all(spacing), padding: const EdgeInsets.all(spacing),
decoration: BoxDecoration( decoration: decoration, // Use the passed decoration
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -319,6 +329,56 @@ Color getColorForStatus(String status) {
/// REDESIGNED INTERNAL UI WIDGETS /// REDESIGNED INTERNAL UI WIDGETS
/// ======================= /// =======================
// NEW WIDGET: Empty Data Card
class _EmptyDataWidget extends StatelessWidget {
const _EmptyDataWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
const double containerHeight = 220;
const double iconSize = 48;
const double spacing = 16.0;
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Header Section
const _DashboardHeader(),
const SizedBox(height: spacing),
// Empty Content Area
SizedBox(
height: containerHeight,
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.info_outline,
color: Colors.grey.shade400,
size: iconSize,
),
const SizedBox(height: 10),
MyText.bodyMedium(
'No purchase invoice data available.',
textAlign: TextAlign.center,
color: Colors.grey.shade500,
),
MyText.bodySmall(
'Please check your data source or filters.',
textAlign: TextAlign.center,
color: Colors.grey.shade400,
),
],
),
),
),
],
);
}
}
class _SectionTitle extends StatelessWidget { class _SectionTitle extends StatelessWidget {
final String title; final String title;
@ -714,4 +774,4 @@ class _ProjectBreakdown extends StatelessWidget {
}).toList(), }).toList(),
); );
} }
} }

View File

@ -22,7 +22,7 @@ class SkeletonLoaders {
height: 16, height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
); );
}), }),
@ -35,19 +35,149 @@ class SkeletonLoaders {
); );
} }
static Widget serviceProjectListSkeletonLoader() {
// --- Start: Configuration to match live UI ---
// Live UI uses ListView.separated with:
// - padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 120)
// - separatorBuilder: MySpacing.height(12)
// - _buildProjectCard uses Card(margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 4))
// To combine:
// Horizontal padding: 8 (ListView) + 6 (Card margin) = 14 on each side.
// Top/Bottom separation: 4 (ListView padding) + 4 (Card margin) = 8
// Separator space: 4 (Card margin) + 12 (Separator) + 4 (Card margin) = 20 total space between cards.
// New ListView.separated padding to compensate for inner Card margins
const EdgeInsets listPadding =
const EdgeInsets.fromLTRB(14, 8, 14, 120 + 4); // 8(L/R) + 6(Card L/R Margin) = 14
// New separator to match the 12 + 4 * 2 = 20 gap.
const Widget cardSeparator = const SizedBox(height: 12);
const EdgeInsets cardMargin = EdgeInsets.zero; // Margin is now controlled by the ListView.separated padding
// Internal Card padding matches the live card
const EdgeInsets cardInnerPadding =
const EdgeInsets.symmetric(horizontal: 18, vertical: 14);
// --- End: Configuration to match live UI ---
return ListView.separated(
padding: listPadding, // Use calculated padding
physics:
const NeverScrollableScrollPhysics(),
itemCount: 4,
separatorBuilder: (_, __) => cardSeparator, // Use calculated separator
itemBuilder: (context, index) {
return Card(
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
margin: cardMargin, // Set margin to zero, handled by ListView padding
shadowColor: Colors.indigo.withOpacity(0.10),
color: Colors.white,
child: ShimmerEffect(
child: Padding(
padding: cardInnerPadding, // Use live card's inner padding
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 1. Title and Status Row
Row(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
// Project Name Placeholder
Container(
height: 18, // Matches MyText.titleMedium height approx
width: 150,
color: Colors.grey.shade300,
),
// Status Chip Placeholder
Container(
height: 18, // Matches status chip height approx
width: 60,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
),
],
),
// MySpacing.height(10) in live UI is the key spacing here
// Note: The live UI has MySpacing.height(4) after the title
// and then MySpacing.height(10) before the first detail row,
// so the total space is 4 + 10 = 14.
MySpacing.height(14),
// 2. Detail Rows (Date, Client, Contact)
// Assigned Date Row
_buildDetailRowSkeleton(
width: 200, iconColor: Colors.teal.shade300),
MySpacing.height(8),
// Client Row
_buildDetailRowSkeleton(
width: 240, iconColor: Colors.indigo.shade300),
MySpacing.height(8),
// Contact Row
_buildDetailRowSkeleton(
width: 220, iconColor: Colors.green.shade300),
MySpacing.height(12), // MySpacing.height(12) before Wrap
// 3. Service Chips Wrap
Wrap(
spacing: 6,
runSpacing: 4,
children: List.generate(
3,
(chipIndex) => Container(
height: 20,
width:
70 + (chipIndex * 10).toDouble(), // Varied widths
decoration: BoxDecoration(
color: Colors
.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
),
),
),
],
),
),
),
);
},
);
}
/// Helper to build a skeleton row for details
static Widget _buildDetailRowSkeleton({
required double width,
required Color iconColor,
}) {
return Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
// Icon Placeholder (size 18 matches live UI)
Icon(Icons.circle, size: 18, color: iconColor),
MySpacing.width(8),
// Text Placeholder (height 13 approx for font size 13)
Container(
height: 14,
width: width,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
),
],
);
}
static Widget attendanceQuickCardSkeleton() { static Widget attendanceQuickCardSkeleton() {
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(5),
gradient: LinearGradient( // ... gradient color setup (using grey for shimmer)
colors: [
Colors.grey.shade300.withOpacity(0.3),
Colors.grey.shade300.withOpacity(0.6),
],
begin: Alignment.topLeft,
end: Alignment.bottomRight,
),
), ),
child: ShimmerEffect( child: ShimmerEffect(
child: Column( child: Column(
@ -56,78 +186,67 @@ class SkeletonLoaders {
// Row with avatar and texts // Row with avatar and texts
Row( Row(
children: [ children: [
// Avatar // Avatar (Size 30)
Container( Container(
width: 30, width: 30,
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, color: Colors.grey.shade400, shape: BoxShape.circle)),
shape: BoxShape.circle,
),
),
MySpacing.width(10), MySpacing.width(10),
// Name + designation // Name + designation (Approximate heights for MyText.titleSmall and MyText.labelSmall)
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
height: 12, height: 12, width: 100, color: Colors.grey.shade400),
width: 100, MySpacing.height(
color: Colors.grey.shade400, 4), // Reduced from 6, guessing labelSmall is shorter
),
MySpacing.height(6),
Container( Container(
height: 10, height: 10, width: 70, color: Colors.grey.shade400),
width: 70,
color: Colors.grey.shade400,
),
], ],
), ),
), ),
// Status // Status (MyText.bodySmall, height approx 12-14)
Container( Container(
height: 12, height: 14,
width: 60, width: 80,
color: Colors.grey.shade400, color: Colors
), .grey.shade400), // Adjusted width and height slightly
], ],
), ),
const SizedBox(height: 12), const SizedBox(height: 12),
// Description // Description (2 lines of Text, font size 13)
Container( Container(
height: 10, height: 14,
width: double.infinity, width: double.infinity,
color: Colors.grey.shade400, color: Colors.grey
), .shade400), // Height for one line of text size 13 + padding
MySpacing.height(6), MySpacing.height(6),
Container( Container(
height: 10, height: 14,
width: double.infinity, width: double.infinity * 0.7,
color: Colors.grey.shade400, color: Colors.grey.shade400), // Shorter second line
),
const SizedBox(height: 12), const SizedBox(height: 12),
// Action buttons // Action buttons (Row at the end)
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
// Check In/Out Button (Approx height 28)
Container( Container(
height: 28, height: 32,
width: 80, width: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, color: Colors.grey.shade400,
borderRadius: BorderRadius.circular(4), borderRadius:
), BorderRadius.circular(5))), // Larger button size
),
MySpacing.width(8), MySpacing.width(8),
// Log View Button (Icon Button, approx size 28-32)
Container( Container(
height: 28, height: 32,
width: 28, width: 32,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, color: Colors.grey.shade400, shape: BoxShape.circle)),
shape: BoxShape.circle,
),
),
], ],
), ),
], ],
@ -139,46 +258,148 @@ class SkeletonLoaders {
static Widget dashboardCardsSkeleton({double? maxWidth}) { static Widget dashboardCardsSkeleton({double? maxWidth}) {
return LayoutBuilder(builder: (context, constraints) { return LayoutBuilder(builder: (context, constraints) {
double width = maxWidth ?? constraints.maxWidth; double width = maxWidth ?? constraints.maxWidth;
int crossAxisCount = (width ~/ 80).clamp(2, 4); double crossAxisSpacing = 15;
double cardWidth = (width - (crossAxisCount - 1) * 6) / crossAxisCount; int crossAxisCount = 3;
return Wrap( // Calculation remains the same: screen_width - (spacing * (count - 1)) / count
spacing: 6, double totalHorizontalSpace =
runSpacing: 6, width - (crossAxisSpacing * (crossAxisCount - 1));
children: List.generate(6, (index) { double cardWidth = totalHorizontalSpace / crossAxisCount;
return MyCard.bordered(
width: cardWidth, // Dynamic height calculation: width / 1.8 (e.g., 92.0 / 1.8 = 51.11, not 46.7)
height: 60, // Rerunning the calculation based on the constraint h=46.7 given in the error:
paddingAll: 4, // If cardWidth = 92.0, the aspect ratio must be different, or the parent widget
borderRadiusAll: 5, // is forcing a smaller height. To fix the overflow, we must assume the target
border: Border.all(color: Colors.grey.withOpacity(0.15)), // height is fixed by the aspect ratio and reduce the inner content size.
child: ShimmerEffect( double cardHeight = cardWidth / 1.8;
child: Column(
mainAxisAlignment: MainAxisAlignment.center, // Inner available vertical space (cardHeight - 2 * paddingAll):
children: [ // If cardHeight is 51.11, inner space is 51.11 - 8 = 43.11.
Container( // If cardHeight is 46.7 (as per error constraint), inner space is 46.7 - 8 = 38.7.
width: 16,
height: 16, return Column(
decoration: BoxDecoration( crossAxisAlignment: CrossAxisAlignment.start,
color: Colors.grey.shade300, children: [
borderRadius: BorderRadius.circular(4), // Skeleton for the "Modules" title (fontSize 16, fontWeight 700)
), Container(
), margin: const EdgeInsets.only(left: 4, bottom: 8),
MySpacing.height(4), height: 18,
Container( width: 80,
width: cardWidth * 0.5, color: Colors.grey.shade300),
height: 10, GridView.builder(
color: Colors.grey.shade300, shrinkWrap: true,
), physics: const NeverScrollableScrollPhysics(),
], padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
), gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: crossAxisCount,
crossAxisSpacing: crossAxisSpacing,
mainAxisSpacing: 8,
childAspectRatio: 1.8,
), ),
); itemCount: 6,
}), itemBuilder: (context, index) {
return MyCard.bordered(
width: cardWidth,
height: cardHeight,
paddingAll: 4,
borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)),
child: ShimmerEffect(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
// Icon placeholder: Reduced size to 16
Container(
width: 16,
height: 16, // Reduced from 20
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5),
),
),
MySpacing.height(4), // Reduced spacing from 6
// Text placeholder 1: Reduced height to 8
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
width: cardWidth * 0.7,
height: 8, // Reduced from 10
color: Colors.grey.shade300,
),
),
MySpacing.height(2), // Reduced spacing from 4
// Text placeholder 2: Reduced height to 8
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Container(
width: cardWidth * 0.5,
height: 8, // Reduced from 10
color: Colors.grey.shade300,
),
),
// Total inner height is now 16 + 4 + 8 + 2 + 8 = 38 pixels.
// This will fit safely within the calculated or constrained height.
],
),
),
);
},
),
],
); );
}); });
} }
static Widget projectSelectorSkeleton() {
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Section Title Skeleton
Container(
margin: const EdgeInsets.only(left: 4, bottom: 8),
height: 18, // For _sectionTitle
width: 80,
color: Colors.grey.shade300,
),
// Selector Card Skeleton
Container(
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
border:
Border.all(color: Colors.grey.shade300), // Placeholder border
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(.04),
blurRadius: 6,
offset: const Offset(0, 2),
),
],
),
child: ShimmerEffect(
child: Row(
children: [
// Icon placeholder
Container(width: 20, height: 20, color: Colors.grey.shade300),
const SizedBox(width: 12),
// Text placeholder
Expanded(
child: Container(
height: 16,
width: double.infinity,
color: Colors.grey.shade300),
),
// Arrow icon placeholder
Container(width: 26, height: 26, color: Colors.grey.shade300),
],
),
),
),
],
);
}
static Widget paymentRequestListSkeletonLoader() { static Widget paymentRequestListSkeletonLoader() {
return ListView.separated( return ListView.separated(
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80), padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
@ -198,7 +419,7 @@ class SkeletonLoaders {
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@ -211,7 +432,7 @@ class SkeletonLoaders {
width: 50, width: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade200, color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -220,7 +441,7 @@ class SkeletonLoaders {
height: 12, height: 12,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
), ),
@ -239,7 +460,7 @@ class SkeletonLoaders {
width: 50, width: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade200, color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@ -248,7 +469,7 @@ class SkeletonLoaders {
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
], ],
@ -260,7 +481,7 @@ class SkeletonLoaders {
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
], ],
@ -281,7 +502,7 @@ class SkeletonLoaders {
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
child: MyCard.bordered( child: MyCard.bordered(
paddingAll: 16, paddingAll: 16,
borderRadiusAll: 8, borderRadiusAll: 5,
shadow: MyShadow(elevation: 3), shadow: MyShadow(elevation: 3),
child: ShimmerEffect( child: ShimmerEffect(
child: Column( child: Column(
@ -345,7 +566,7 @@ class SkeletonLoaders {
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
), ),
), ),
)), )),
@ -414,7 +635,7 @@ class SkeletonLoaders {
children: [ children: [
// Header skeleton (avatar + name + role) // Header skeleton (avatar + name + role)
MyCard( MyCard(
borderRadiusAll: 8, borderRadiusAll: 5,
paddingAll: 16, paddingAll: 16,
margin: MySpacing.bottom(16), margin: MySpacing.bottom(16),
shadow: MyShadow(elevation: 2), shadow: MyShadow(elevation: 2),
@ -465,7 +686,7 @@ class SkeletonLoaders {
(_) => Column( (_) => Column(
children: [ children: [
MyCard( MyCard(
borderRadiusAll: 8, borderRadiusAll: 5,
paddingAll: 16, paddingAll: 16,
margin: MySpacing.bottom(16), margin: MySpacing.bottom(16),
shadow: MyShadow(elevation: 2), shadow: MyShadow(elevation: 2),
@ -552,7 +773,7 @@ class SkeletonLoaders {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(3, (floorIndex) { children: List.generate(3, (floorIndex) {
return MyCard( return MyCard(
borderRadiusAll: 8, borderRadiusAll: 5,
paddingAll: 5, paddingAll: 5,
margin: MySpacing.bottom(10), margin: MySpacing.bottom(10),
shadow: MyShadow(elevation: 1.5), shadow: MyShadow(elevation: 1.5),
@ -566,7 +787,7 @@ class SkeletonLoaders {
width: 160, width: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
MySpacing.height(10), MySpacing.height(10),
@ -588,7 +809,7 @@ class SkeletonLoaders {
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
MySpacing.height(8), MySpacing.height(8),
@ -617,7 +838,7 @@ class SkeletonLoaders {
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: borderRadius:
BorderRadius.circular(4), BorderRadius.circular(5),
), ),
), ),
), ),
@ -642,7 +863,7 @@ class SkeletonLoaders {
static Widget chartSkeletonLoader() { static Widget chartSkeletonLoader() {
return MyCard.bordered( return MyCard.bordered(
paddingAll: 16, paddingAll: 16,
borderRadiusAll: 12, borderRadiusAll: 5,
shadow: MyShadow( shadow: MyShadow(
elevation: 1.5, elevation: 1.5,
position: MyShadowPosition.bottom, position: MyShadowPosition.bottom,
@ -657,7 +878,7 @@ class SkeletonLoaders {
width: 180, width: 180,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -686,7 +907,7 @@ class SkeletonLoaders {
height: 14, height: 14,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
); );
}), }),
@ -704,7 +925,7 @@ class SkeletonLoaders {
width: 90, width: 90,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
); );
@ -735,7 +956,7 @@ class SkeletonLoaders {
width: 160, width: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -767,7 +988,7 @@ class SkeletonLoaders {
width: 100, width: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@ -776,7 +997,7 @@ class SkeletonLoaders {
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
], ],
@ -789,7 +1010,7 @@ class SkeletonLoaders {
width: 30, width: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@ -816,7 +1037,7 @@ class SkeletonLoaders {
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -825,7 +1046,7 @@ class SkeletonLoaders {
width: 140, width: 140,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
], ],
@ -835,7 +1056,7 @@ class SkeletonLoaders {
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(4), borderRadius: BorderRadius.circular(5),
), ),
), ),
], ],
@ -847,8 +1068,10 @@ class SkeletonLoaders {
} }
static Widget documentSkeletonLoader() { static Widget documentSkeletonLoader() {
return Column( return ListView.builder(
children: List.generate(5, (index) { padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 0),
itemCount: 5,
itemBuilder: (context, index) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -861,7 +1084,7 @@ class SkeletonLoaders {
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
), ),
@ -873,7 +1096,7 @@ class SkeletonLoaders {
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.05), color: Colors.black.withOpacity(0.05),
@ -891,7 +1114,7 @@ class SkeletonLoaders {
padding: const EdgeInsets.all(10), padding: const EdgeInsets.all(10),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
), ),
child: const Icon(Icons.description, child: const Icon(Icons.description,
color: Colors.transparent), // invisible icon color: Colors.transparent), // invisible icon
@ -939,7 +1162,7 @@ class SkeletonLoaders {
), ),
], ],
); );
}), },
); );
} }
@ -955,7 +1178,7 @@ class SkeletonLoaders {
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(10), borderRadius: BorderRadius.circular(5),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.06), color: Colors.black.withOpacity(0.06),
@ -1012,7 +1235,7 @@ class SkeletonLoaders {
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12), borderRadius: BorderRadius.circular(5),
), ),
); );
}), }),
@ -1066,7 +1289,7 @@ class SkeletonLoaders {
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -1112,7 +1335,7 @@ class SkeletonLoaders {
return Column( return Column(
children: List.generate(4, (index) { children: List.generate(4, (index) {
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 12, borderRadiusAll: 5,
paddingAll: 10, paddingAll: 10,
margin: MySpacing.bottom(12), margin: MySpacing.bottom(12),
shadow: MyShadow(elevation: 3), shadow: MyShadow(elevation: 3),
@ -1184,7 +1407,7 @@ class SkeletonLoaders {
static Widget employeeListCollapsedSkeletonLoader() { static Widget employeeListCollapsedSkeletonLoader() {
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 4, borderRadiusAll: 5,
paddingAll: 8, paddingAll: 8,
child: ShimmerEffect( child: ShimmerEffect(
child: Column( child: Column(
@ -1256,7 +1479,7 @@ class SkeletonLoaders {
static Widget dailyProgressReportSkeletonLoader() { static Widget dailyProgressReportSkeletonLoader() {
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 4, borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.2)), border: Border.all(color: Colors.grey.withOpacity(0.2)),
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom), shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
paddingAll: 8, paddingAll: 8,
@ -1291,7 +1514,7 @@ class SkeletonLoaders {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(3, (index) { children: List.generate(3, (index) {
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 12, borderRadiusAll: 5,
paddingAll: 16, paddingAll: 16,
margin: MySpacing.bottom(12), margin: MySpacing.bottom(12),
shadow: MyShadow(elevation: 3), shadow: MyShadow(elevation: 3),
@ -1350,7 +1573,7 @@ class SkeletonLoaders {
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
Container( Container(
@ -1358,7 +1581,7 @@ class SkeletonLoaders {
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
], ],
@ -1372,7 +1595,7 @@ class SkeletonLoaders {
width: 100, width: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
const Spacer(), const Spacer(),
@ -1381,7 +1604,7 @@ class SkeletonLoaders {
width: 50, width: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(5),
), ),
), ),
], ],
@ -1397,7 +1620,7 @@ class SkeletonLoaders {
return MyCard.bordered( return MyCard.bordered(
margin: MySpacing.only(bottom: 12), margin: MySpacing.only(bottom: 12),
paddingAll: 12, paddingAll: 12,
borderRadiusAll: 12, borderRadiusAll: 5,
shadow: MyShadow( shadow: MyShadow(
elevation: 1.5, elevation: 1.5,
position: MyShadowPosition.bottom, position: MyShadowPosition.bottom,
@ -1480,9 +1703,8 @@ class SkeletonLoaders {
return MyCard.bordered( return MyCard.bordered(
margin: MySpacing.only(bottom: 12), margin: MySpacing.only(bottom: 12),
paddingAll: 16, paddingAll: 16,
borderRadiusAll: 16, borderRadiusAll: 5,
shadow: MyShadow( shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom, position: MyShadowPosition.bottom,
), ),
child: ShimmerEffect( child: ShimmerEffect(
@ -1636,7 +1858,7 @@ class SkeletonLoaders {
// Aging Stacked Bar Placeholder // Aging Stacked Bar Placeholder
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(8), borderRadius: BorderRadius.circular(5),
child: Row( child: Row(
children: List.generate( children: List.generate(
4, 4,
@ -1845,41 +2067,28 @@ class SkeletonLoaders {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
// Legend/Details Placeholder // Legend/Details Placeholder
Expanded( // Aging Legend Placeholders
child: Column( Wrap(
crossAxisAlignment: CrossAxisAlignment.start, spacing: 12,
children: List.generate( runSpacing: 8,
3, children: List.generate(
(index) => Padding( 4,
padding: const EdgeInsets.only(bottom: 8.0), (index) => Row(
child: Row( mainAxisSize: MainAxisSize.min,
mainAxisAlignment: children: [
MainAxisAlignment.spaceBetween, Container(
children: [ width: 10,
Row( height: 10,
children: [ decoration: BoxDecoration(
Container( color: Colors.grey.shade300,
width: 8, shape: BoxShape.circle)),
height: 8, const SizedBox(width: 6),
margin: Container(
const EdgeInsets.only(right: 8), height: 12,
decoration: BoxDecoration( width: 115, // Reduced from 120
color: Colors.grey.shade300, color: Colors.grey.shade300),
shape: BoxShape.circle)), ],
Container( )),
height: 12,
width: 80,
color: Colors.grey.shade300),
],
),
Container(
height: 14,
width: 50,
color: Colors.grey.shade300),
],
),
)),
),
), ),
], ],
), ),

View File

@ -1,8 +1,9 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class PillTabBar extends StatelessWidget { class PillTabBar extends StatefulWidget {
final TabController controller; final TabController controller;
final List<String> tabs; final List<String> tabs;
final List<IconData> icons;
final Color selectedColor; final Color selectedColor;
final Color unselectedColor; final Color unselectedColor;
final Color indicatorColor; final Color indicatorColor;
@ -13,6 +14,7 @@ class PillTabBar extends StatelessWidget {
Key? key, Key? key,
required this.controller, required this.controller,
required this.tabs, required this.tabs,
required this.icons,
this.selectedColor = Colors.blue, this.selectedColor = Colors.blue,
this.unselectedColor = Colors.grey, this.unselectedColor = Colors.grey,
this.indicatorColor = Colors.blueAccent, this.indicatorColor = Colors.blueAccent,
@ -21,64 +23,80 @@ class PillTabBar extends StatelessWidget {
}) : super(key: key); }) : super(key: key);
@override @override
Widget build(BuildContext context) { State<PillTabBar> createState() => _PillTabBarState();
// Dynamic horizontal padding between tabs }
final screenWidth = MediaQuery.of(context).size.width;
final tabSpacing = (screenWidth / (tabs.length * 12)).clamp(8.0, 24.0);
class _PillTabBarState extends State<PillTabBar> {
@override
void initState() {
super.initState();
widget.controller.addListener(_onTabChange);
}
void _onTabChange() {
if (mounted) setState(() {});
}
@override
void dispose() {
widget.controller.removeListener(_onTabChange);
super.dispose();
}
@override
Widget build(BuildContext context) {
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container( child: Container(
height: height, height: widget.height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(height / 2), borderRadius: BorderRadius.circular(widget.height / 2),
boxShadow: [ ),
BoxShadow( child: TabBar(
color: Colors.grey.withOpacity(0.15), controller: widget.controller,
blurRadius: 4, isScrollable: true, // important for dynamic spacing
offset: const Offset(0, 2), indicatorSize: TabBarIndicatorSize.tab,
indicator: BoxDecoration(
color: widget.indicatorColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(widget.height / 2),
), ),
], onTap: widget.onTap,
), tabs: List.generate(widget.tabs.length, (index) {
child: TabBar( final isSelected = widget.controller.index == index;
controller: controller,
indicator: BoxDecoration( return AnimatedContainer(
color: indicatorColor.withOpacity(0.2), duration: const Duration(milliseconds: 200),
borderRadius: BorderRadius.circular(height / 2), padding: EdgeInsets.symmetric(
), horizontal:
indicatorSize: TabBarIndicatorSize.tab, isSelected ? 12 : 6, // reduce padding for unselected tabs
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,
),
),
), ),
) child: Row(
.toList(), mainAxisSize: MainAxisSize.min,
onTap: onTap, children: [
), Icon(
), widget.icons[index],
size: isSelected ? 18 : 16,
color: isSelected
? widget.selectedColor
: widget.unselectedColor,
),
if (isSelected) ...[
const SizedBox(width: 4),
Text(
widget.tabs[index],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500,
color: widget.selectedColor,
),
),
],
],
),
);
}),
)),
); );
} }
} }

View File

@ -92,7 +92,7 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
); );
if (success) { if (success) {
widget.attendanceController.fetchEmployeesByProject(selectedProjectId); widget.attendanceController.fetchTodaysAttendance(selectedProjectId);
widget.attendanceController.fetchAttendanceLogs(selectedProjectId); widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
await widget.attendanceController await widget.attendanceController
.fetchRegularizationLogs(selectedProjectId); .fetchRegularizationLogs(selectedProjectId);

View File

@ -24,9 +24,11 @@ class AssignTaskBottomSheet extends StatefulWidget {
final String buildingName; final String buildingName;
final String floorName; final String floorName;
final String workAreaName; final String workAreaName;
final String buildingId;
const AssignTaskBottomSheet({ const AssignTaskBottomSheet({
super.key, super.key,
required this.buildingId,
required this.buildingName, required this.buildingName,
required this.workLocation, required this.workLocation,
required this.floorName, required this.floorName,
@ -82,10 +84,6 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
serviceId: selectedService?.id, serviceId: selectedService?.id,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
); );
await controller.fetchTaskData(
selectedProjectId,
serviceId: selectedService?.id,
);
} }
@override @override
@ -376,7 +374,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
} }
} }
void _onAssignTaskPressed() { Future<void> _onAssignTaskPressed() async {
final selectedTeam = controller.selectedEmployees; final selectedTeam = controller.selectedEmployees;
if (selectedTeam.isEmpty) { if (selectedTeam.isEmpty) {
@ -417,14 +415,20 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return; return;
} }
controller.assignDailyTask( final success = await controller.assignDailyTask(
workItemId: widget.workItemId, workItemId: widget.workItemId,
plannedTask: target.toInt(), plannedTask: target.toInt(),
description: description, description: description,
taskTeam: selectedTeam.map((e) => e.id).toList(), // pass IDs taskTeam: selectedTeam.map((e) => e.id).toList(),
assignmentDate: widget.assignmentDate, assignmentDate: widget.assignmentDate,
buildingId: widget.buildingId,
projectId: selectedProjectId!,
organizationId: selectedOrganization?.id, organizationId: selectedOrganization?.id,
serviceId: selectedService?.id, serviceId: selectedService?.id,
); );
if (success) {
Navigator.pop(context);
}
} }
} }

View File

@ -35,11 +35,13 @@ class _UserDocumentFilterBottomSheetState
if (filterData == null) return const SizedBox.shrink(); if (filterData == null) return const SizedBox.shrink();
final hasFilters = [ final hasFilters = [
filterData.uploadedBy, filterData.uploadedBy,
filterData.documentCategory, filterData.documentCategory,
filterData.documentType, filterData.documentType,
filterData.documentTag, filterData.documentTag,
].any((list) => list.isNotEmpty); ].any((list) => list.isNotEmpty) ||
docController.startDate.value != null ||
docController.endDate.value != null;
return BaseBottomSheet( return BaseBottomSheet(
title: 'Filter Documents', title: 'Filter Documents',
@ -53,8 +55,8 @@ class _UserDocumentFilterBottomSheetState
'documentTypeIds': docController.selectedType.toList(), 'documentTypeIds': docController.selectedType.toList(),
'documentTagIds': docController.selectedTag.toList(), 'documentTagIds': docController.selectedTag.toList(),
'isUploadedAt': docController.isUploadedAt.value, 'isUploadedAt': docController.isUploadedAt.value,
'startDate': docController.startDate.value, 'startDate': docController.startDate.value?.toIso8601String(),
'endDate': docController.endDate.value, 'endDate': docController.endDate.value?.toIso8601String(),
if (docController.isVerified.value != null) if (docController.isVerified.value != null)
'isVerified': docController.isVerified.value, 'isVerified': docController.isVerified.value,
}; };

View File

@ -52,6 +52,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
final GlobalKey _expenseTypeDropdownKey = GlobalKey(); final GlobalKey _expenseTypeDropdownKey = GlobalKey();
final GlobalKey _paymentModeDropdownKey = GlobalKey(); final GlobalKey _paymentModeDropdownKey = GlobalKey();
@override
void initState() {
super.initState();
if (widget.isEdit && widget.existingExpense != null) {
controller.populateFieldsForEdit(widget.existingExpense!);
}
}
Future<void> _showEmployeeList() async { Future<void> _showEmployeeList() async {
final result = await showModalBottomSheet<dynamic>( final result = await showModalBottomSheet<dynamic>(
context: context, context: context,
@ -217,13 +225,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
), ),
], ],
_gap(), _gap(),
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "GST No.",
controller: controller.gstController,
hint: "Enter GST No.",
),
_gap(),
_buildDropdownField<PaymentModeModel>( _buildDropdownField<PaymentModeModel>(
icon: Icons.payment, icon: Icons.payment,
title: "Payment Mode", title: "Payment Mode",
@ -239,6 +240,29 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
dropdownKey: _paymentModeDropdownKey, dropdownKey: _paymentModeDropdownKey,
), ),
_gap(), _gap(),
Obx(() {
if (controller.isTransactionIdExempted.value) {
return const SizedBox.shrink(); // hide field
}
return Column(
children: [
_buildTextFieldSection(
icon: Icons.confirmation_number_outlined,
title: "Transaction ID",
hint: "Enter Transaction ID",
controller: controller.transactionIdController,
isRequiredOverride: true,
validator: (v) {
return (v != null && v.isNotEmpty)
? Validators.transactionIdValidator(v)
: Validators.requiredField(v);
},
),
_gap(),
],
);
}),
_buildPaidBySection(), _buildPaidBySection(),
_gap(), _gap(),
_buildTextFieldSection( _buildTextFieldSection(
@ -262,12 +286,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
_gap(), _gap(),
_buildTextFieldSection( _buildTextFieldSection(
icon: Icons.confirmation_number_outlined, icon: Icons.confirmation_number_outlined,
title: "Transaction ID", title: "GST No.",
controller: controller.transactionIdController, controller: controller.gstController,
hint: "Enter Transaction ID", hint: "Enter GST No.",
validator: (v) => (v != null && v.isNotEmpty)
? Validators.transactionIdValidator(v)
: null,
), ),
_gap(), _gap(),
_buildTransactionDateField(), _buildTransactionDateField(),
@ -321,12 +342,18 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
TextInputType? keyboardType, TextInputType? keyboardType,
FormFieldValidator<String>? validator, FormFieldValidator<String>? validator,
int maxLines = 1, int maxLines = 1,
bool? isRequiredOverride,
}) { }) {
final bool isRequired = isRequiredOverride ?? (validator != null);
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
SectionTitle( SectionTitle(
icon: icon, title: title, requiredField: validator != null), icon: icon,
title: title,
requiredField: isRequired
),
MySpacing.height(6), MySpacing.height(6),
CustomTextField( CustomTextField(
controller: controller, controller: controller,

View File

@ -41,7 +41,7 @@ class ReimbursementBottomSheet extends StatefulWidget {
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> { class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
final ExpenseDetailController controller = final ExpenseDetailController controller =
Get.find<ExpenseDetailController>(); Get.put(ExpenseDetailController());
final TextEditingController commentCtrl = TextEditingController(); final TextEditingController commentCtrl = TextEditingController();
final TextEditingController txnCtrl = TextEditingController(); final TextEditingController txnCtrl = TextEditingController();
@ -197,7 +197,7 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
return; return;
} }
if (expenseTransactionDate != null && selectedDate != null) { if (expenseTransactionDate != null) {
final normalizedSelected = DateTime( final normalizedSelected = DateTime(
selectedDate.year, selectedDate.year,
selectedDate.month, selectedDate.month,

View File

@ -0,0 +1,25 @@
class AssignProjectAllocationRequest {
final String employeeId;
final String projectId;
final String jobRoleId;
final String serviceId;
final bool status;
AssignProjectAllocationRequest({
required this.employeeId,
required this.projectId,
required this.jobRoleId,
required this.serviceId,
required this.status,
});
Map<String, dynamic> toJson() {
return {
"employeeId": employeeId,
"projectId": projectId,
"jobRoleId": jobRoleId,
"serviceId": serviceId,
"status": status,
};
}
}

View File

@ -0,0 +1,119 @@
class ProjectAllocationResponse {
final bool success;
final String message;
final List<ProjectAllocation> data;
final int statusCode;
final String timestamp;
ProjectAllocationResponse({
required this.success,
required this.message,
required this.data,
required this.statusCode,
required this.timestamp,
});
factory ProjectAllocationResponse.fromJson(Map<String, dynamic> json) {
final List<dynamic>? rawData = json['data'] as List<dynamic>?;
final List<ProjectAllocation> allocations = rawData
?.map((item) => ProjectAllocation.fromJson(item as Map<String, dynamic>))
.toList()
?? [];
return ProjectAllocationResponse(
success: json['success'] as bool? ?? false,
message: json['message'] as String? ?? 'An unknown API error occurred.',
data: allocations,
statusCode: json['statusCode'] as int? ?? 0,
timestamp: json['timestamp'] as String? ?? '',
);
}
/// Converts the [ProjectAllocationResponse] object back to a JSON map.
Map<String, dynamic> toJson() {
return {
'success': success,
'message': message,
'data': data.map((e) => e.toJson()).toList(),
'statusCode': statusCode,
'timestamp': timestamp,
};
}
}
// --- Allocation Detail Class ---
class ProjectAllocation {
final String id;
final String employeeId;
final String projectId;
final String allocationDate;
final bool isActive;
final String firstName;
final String lastName;
final String middleName;
final String organizationId;
final String organizationName;
final String serviceId;
final String serviceName;
final String jobRoleId;
final String jobRoleName;
ProjectAllocation({
required this.id,
required this.employeeId,
required this.projectId,
required this.allocationDate,
required this.isActive,
required this.firstName,
required this.lastName,
required this.middleName,
required this.organizationId,
required this.organizationName,
required this.serviceId,
required this.serviceName,
required this.jobRoleId,
required this.jobRoleName
});
factory ProjectAllocation.fromJson(Map<String, dynamic> json) {
return ProjectAllocation(
id: json['id'] as String? ?? '',
employeeId: json['employeeId'] as String? ?? '',
projectId: json['projectId'] as String? ?? '',
allocationDate: json['allocationDate'] as String? ?? '',
isActive: json['isActive'] as bool? ?? false,
firstName: json['firstName'] as String? ?? '',
lastName: json['lastName'] as String? ?? '',
middleName: json['middleName'] as String? ?? '',
organizationId: json['organizationId'] as String? ?? '',
organizationName: json['organizationName'] as String? ?? '',
serviceId: json['serviceId'] as String? ?? '',
serviceName: json['serviceName'] as String? ?? '',
jobRoleId: json['jobRoleId'] as String? ?? '',
jobRoleName: json['jobRoleName'] as String? ?? '',
);
}
Map<String, dynamic> toJson() {
return {
'id': id,
'employeeId': employeeId,
'projectId': projectId,
'allocationDate': allocationDate,
'isActive': isActive,
'firstName': firstName,
'lastName': lastName,
'middleName': middleName,
'organizationId': organizationId,
'organizationName': organizationName,
'serviceId': serviceId,
'serviceName': serviceName,
'jobRoleId': jobRoleId,
'jobRoleName': jobRoleName
};
}
}

View File

@ -1,7 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package:on_field_work/helpers/services/auth_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart';
import 'package:on_field_work/view/auth/forgot_password_screen.dart'; import 'package:on_field_work/view/auth/forgot_password_screen.dart';
import 'package:on_field_work/view/auth/login_screen.dart'; import 'package:on_field_work/view/auth/login_screen.dart';
import 'package:on_field_work/view/auth/register_account_screen.dart'; import 'package:on_field_work/view/auth/register_account_screen.dart';
@ -32,7 +31,7 @@ class AuthMiddleware extends GetMiddleware {
if (route != '/auth/login-option') { if (route != '/auth/login-option') {
return const RouteSettings(name: '/auth/login-option'); return const RouteSettings(name: '/auth/login-option');
} }
} else if (!TenantService.isTenantSelected) { } else if (!AuthService.isTenantSelected) {
if (route != '/select-tenant') { if (route != '/select-tenant') {
return const RouteSettings(name: '/select-tenant'); return const RouteSettings(name: '/select-tenant');
} }

View File

@ -31,7 +31,7 @@ class _AttendanceScreenState extends State<AttendanceScreen>
final projectController = Get.put(ProjectController()); final projectController = Get.put(ProjectController());
late TabController _tabController; late TabController _tabController;
late List<Map<String, String>> _tabs; late List<Map<String, dynamic>> _tabs;
bool _tabsInitialized = false; bool _tabsInitialized = false;
@override @override
@ -62,9 +62,13 @@ class _AttendanceScreenState extends State<AttendanceScreen>
void _initializeTabs() async { void _initializeTabs() async {
final allTabs = [ final allTabs = [
{'label': "Today's", 'value': 'todaysAttendance'}, {'label': "Today's", 'value': 'todaysAttendance', 'icon': Icons.today},
{'label': "Logs", 'value': 'attendanceLogs'}, {'label': "Logs", 'value': 'attendanceLogs', 'icon': Icons.list_alt},
{'label': "Regularization", 'value': 'regularizationRequests'}, {
'label': "Regularization",
'value': 'regularizationRequests',
'icon': Icons.edit
},
]; ];
final hasRegularizationPermission = final hasRegularizationPermission =
@ -306,7 +310,11 @@ class _AttendanceScreenState extends State<AttendanceScreen>
padding: const EdgeInsets.symmetric(horizontal: 8), padding: const EdgeInsets.symmetric(horizontal: 8),
child: PillTabBar( child: PillTabBar(
controller: _tabController, controller: _tabController,
tabs: _tabs.map((e) => e['label']!).toList(), tabs:
_tabs.map((e) => e['label'] as String).toList(),
icons: _tabs
.map((e) => e['icon'] as IconData)
.toList(),
selectedColor: contentTheme.primary, selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600, unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary, indicatorColor: contentTheme.primary,

View File

@ -123,14 +123,33 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
Widget _buildWelcomeText() { Widget _buildWelcomeText() {
return Column( return Column(
children: [ children: [
MyText( RichText(
"Welcome to On Field Work",
fontSize: 24,
fontWeight: 600,
color: Colors.black87,
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan(
style: const TextStyle(
fontSize: 24,
fontWeight: FontWeight.w600,
color: Colors.black87,
),
children: const [
TextSpan(
text: "Welcome to ",
style: TextStyle(color: Colors.black87),
),
TextSpan(
text: "OnField",
style: TextStyle(color: Color(0xFF007BFF)), // Blue
),
TextSpan(
text: "Work",
style: TextStyle(color: Color(0xFF71DD37)), // Green
),
],
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
MyText( MyText(
"Streamline Project Management\nBoost Productivity with Automation.", "Streamline Project Management\nBoost Productivity with Automation.",
fontSize: 14, fontSize: 14,
@ -254,7 +273,11 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
Widget _buildBackButton() { Widget _buildBackButton() {
return TextButton.icon( return TextButton.icon(
onPressed: () async => await LocalStorage.logout(), onPressed: () async => await LocalStorage.logout(),
icon: Icon(Icons.arrow_back, size: 18, color: contentTheme.primary,), icon: Icon(
Icons.arrow_back,
size: 18,
color: contentTheme.primary,
),
label: MyText.bodyMedium( label: MyText.bodyMedium(
'Back to Login', 'Back to Login',
color: contentTheme.primary, color: contentTheme.primary,

View File

@ -196,14 +196,33 @@ class _WelcomeScreenState extends State<WelcomeScreen>
Widget _buildWelcomeText() { Widget _buildWelcomeText() {
return Column( return Column(
children: [ children: [
MyText( RichText(
"Welcome to On Field Work",
fontSize: 26,
fontWeight: 800,
color: Colors.black87,
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: TextSpan(
style: const TextStyle(
fontSize: 26,
fontWeight: FontWeight.w800,
color: Colors.black87,
),
children: const [
TextSpan(
text: "Welcome to ",
style: TextStyle(color: Colors.black87),
),
TextSpan(
text: "OnField",
style: TextStyle(color: Color(0xFF007BFF)), // Blue
),
TextSpan(
text: "Work",
style: TextStyle(color: Color(0xFF71DD37)), // Green
),
],
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
MyText( MyText(
"Streamline Project Management\nBoost Productivity with Automation.", "Streamline Project Management\nBoost Productivity with Automation.",
fontSize: 14, fontSize: 14,

View File

@ -10,7 +10,6 @@ import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/wave_background.dart'; import 'package:on_field_work/helpers/widgets/wave_background.dart';
class MPINAuthScreen extends StatefulWidget { class MPINAuthScreen extends StatefulWidget {
const MPINAuthScreen({super.key}); const MPINAuthScreen({super.key});
@ -91,12 +90,31 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 12),
MyText( RichText(
"Welcome to On Field Work",
fontSize: 24,
fontWeight: 800,
color: Colors.black87,
textAlign: TextAlign.center, textAlign: TextAlign.center,
text: const TextSpan(
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.black87,
),
children: [
TextSpan(
text: "Welcome to ",
style: TextStyle(color: Colors.black87),
),
TextSpan(
text: "OnField",
style: TextStyle(
color: Color(0xFF007BFF)), // Blue
),
TextSpan(
text: "Work",
style: TextStyle(
color: Color(0xFF71DD37)), // Green
),
],
),
), ),
const SizedBox(height: 10), const SizedBox(height: 10),
MyText( MyText(
@ -317,8 +335,8 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
if (isNewUser || isChangeMpin) if (isNewUser || isChangeMpin)
TextButton.icon( TextButton.icon(
onPressed: () => Get.toNamed('/dashboard'), onPressed: () => Get.toNamed('/dashboard'),
icon: Icon(Icons.arrow_back, icon:
size: 18, color: contentTheme.primary), Icon(Icons.arrow_back, size: 18, color: contentTheme.primary),
label: MyText.bodyMedium( label: MyText.bodyMedium(
'Back to Home Page', 'Back to Home Page',
color: contentTheme.primary, color: contentTheme.primary,

View File

@ -11,8 +11,8 @@ 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/avatar.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_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/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/collection_dashboard_card.dart'; // Unused
import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart'; // import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart'; // Unused
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.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_custom_skeleton.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart'; import 'package:on_field_work/helpers/widgets/my_spacing.dart';
@ -34,7 +34,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final AttendanceController attendanceController = final AttendanceController attendanceController =
Get.put(AttendanceController()); Get.put(AttendanceController());
final DynamicMenuController menuController = Get.put(DynamicMenuController()); final DynamicMenuController menuController = Get.put(DynamicMenuController());
final ProjectController projectController = Get.find<ProjectController>(); final ProjectController projectController = Get.put(ProjectController());
bool hasMpin = true; bool hasMpin = true;
@ -56,34 +56,35 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
Widget _cardWrapper({required Widget child}) { Widget _cardWrapper({required Widget child}) {
const BorderRadius cardRadius = BorderRadius.all(Radius.circular(5));
const List<BoxShadow> cardShadow = [
BoxShadow(
color: Color.fromRGBO(0, 0, 0, 0.05),
blurRadius: 12,
offset: Offset(0, 4),
),
];
return Container( return Container(
margin: const EdgeInsets.only(bottom: 16), margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(5), borderRadius: cardRadius,
border: Border.all(color: Colors.black12.withOpacity(.04)), border: Border.all(color: Colors.black12.withOpacity(.04)),
boxShadow: [ boxShadow: cardShadow,
BoxShadow(
color: Colors.black12.withOpacity(.05),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
), ),
child: child, child: child,
); );
} }
Widget _sectionTitle(String title) { Widget _sectionTitle(String title) {
return Padding( return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8), padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text( child: MyText.titleMedium(
title, title,
style: const TextStyle( fontWeight: 700,
fontSize: 16, color: Colors.black87,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
), ),
); );
} }
@ -120,9 +121,33 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
), ),
child: const Text( child: Column(
'No attendance data available', crossAxisAlignment: CrossAxisAlignment.start,
style: TextStyle(color: Colors.white), mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.info_outline,
size: 30, color: Colors.white),
MySpacing.width(10),
const Expanded(
child: Text(
"No attendance data available yet.",
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
MySpacing.height(12),
const Text(
"You are not added to this project or attendance data is not available.",
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
), ),
); );
} }
@ -137,6 +162,12 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
? 'Checked In' ? 'Checked In'
: 'Checked Out'; : 'Checked Out';
final String infoText = !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.';
return Container( return Container(
padding: const EdgeInsets.all(16), padding: const EdgeInsets.all(16),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -185,19 +216,15 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
], ],
), ),
const SizedBox(height: 12), MySpacing.height(12),
Text( Text(
!isCheckedIn infoText,
? '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( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 13, fontSize: 13,
), ),
), ),
const SizedBox(height: 12), MySpacing.height(12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -236,8 +263,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final bool projectSelected = projectController.selectedProject != null; final bool projectSelected = projectController.selectedProject != null;
// these are String constants from permission_constants.dart const List<String> cardOrder = [
final List<String> cardOrder = [
MenuItems.attendance, MenuItems.attendance,
MenuItems.employees, MenuItems.employees,
MenuItems.directory, MenuItems.directory,
@ -280,14 +306,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
const Text( _sectionTitle('Modules'),
'Modules',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
if (!projectSelected) if (!projectSelected)
Container( Container(
padding: const EdgeInsets.symmetric( padding: const EdgeInsets.symmetric(
@ -312,7 +331,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(), // Important!
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2), padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount( gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3, crossAxisCount: 3,
@ -326,8 +345,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final item = allowed[id]!; final item = allowed[id]!;
final _DashboardCardMeta cardMeta = meta[id]!; final _DashboardCardMeta cardMeta = meta[id]!;
// Attendance is the only module not requiring a project
final bool isEnabled = final bool isEnabled =
item.name == 'Attendance' ? true : projectSelected; item.id == MenuItems.attendance ? true : projectSelected;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
@ -371,7 +391,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
color: color:
isEnabled ? cardMeta.color : Colors.grey.shade300, isEnabled ? cardMeta.color : Colors.grey.shade300,
), ),
const SizedBox(height: 6), MySpacing.height(6),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text( child: Text(
@ -413,10 +433,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final String? selectedId = projectController.selectedProjectId.value; final String? selectedId = projectController.selectedProjectId.value;
if (isLoading) { if (isLoading) {
return SkeletonLoaders.dashboardCardsSkeleton( return SkeletonLoaders.projectSelectorSkeleton();
maxWidth: MediaQuery.of(context).size.width,
);
} }
final String selectedProjectName = projects
.firstWhereOrNull(
(p) => p.id == selectedId,
)
?.name ??
'Select Project';
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@ -445,15 +469,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
color: Colors.blue, color: Colors.blue,
size: 20, size: 20,
), ),
const SizedBox(width: 12), MySpacing.width(12),
Expanded( Expanded(
child: Text( child: Text(
projects selectedProjectName,
.firstWhereOrNull(
(p) => p.id == selectedId,
)
?.name ??
'Select Project',
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -498,17 +517,17 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
child: Column( child: Column(
children: [ children: [
TextField( const TextField(
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search project...', hintText: 'Search project...',
isDense: true, isDense: true,
prefixIcon: const Icon(Icons.search), prefixIcon: Icon(Icons.search),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.all(Radius.circular(5)),
), ),
), ),
), ),
const SizedBox(height: 10), MySpacing.height(10),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: projects.length, itemCount: projects.length,
@ -534,51 +553,62 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
); );
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Build // Build (MODIFIED FOR FIXED HEADER)
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xfff5f6fa), backgroundColor: const Color(0xfff5f6fa),
body: Layout( body: Layout(
child: SingleChildScrollView( child: Stack(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0), children: [
child: Column( // Main content
crossAxisAlignment: CrossAxisAlignment.start, Padding(
children: [ padding:
_projectSelector(), const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
MySpacing.height(20), child: Column(
_quickActions(), crossAxisAlignment: CrossAxisAlignment.start,
MySpacing.height(20), children: [
_dashboardModules(), _projectSelector(),
MySpacing.height(20), MySpacing.height(20),
_sectionTitle('Reports & Analytics'), Expanded(
CompactPurchaseInvoiceDashboard(), child: SingleChildScrollView(
MySpacing.height(20), child: Column(
CollectionsHealthWidget(), crossAxisAlignment: CrossAxisAlignment.start,
MySpacing.height(20), children: [
_cardWrapper( _quickActions(),
child: ExpenseTypeReportChart(), MySpacing.height(20),
), _dashboardModules(),
_cardWrapper( MySpacing.height(20),
child: ExpenseByStatusWidget( _sectionTitle('Reports & Analytics'),
controller: dashboardController, _cardWrapper(
child: ExpenseTypeReportChart(),
),
_cardWrapper(
child: ExpenseByStatusWidget(
controller: dashboardController,
),
),
_cardWrapper(
child: MonthlyExpenseDashboardChart(),
),
MySpacing.height(80), // give space under content
],
),
),
), ),
), ],
_cardWrapper( ),
child: MonthlyExpenseDashboardChart(),
),
MySpacing.height(20),
],
), ),
), ],
), ),
); ),
} );
}
} }
class _DashboardCardMeta { class _DashboardCardMeta {
final IconData icon; final IconData icon;
final Color color; final Color color;

View File

@ -70,6 +70,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
appBar: CustomAppBar( appBar: CustomAppBar(
title: 'Contact Profile', title: 'Contact Profile',
backgroundColor: appBarColor, backgroundColor: appBarColor,
projectName: " All Projects",
onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'), onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
), ),

View File

@ -46,6 +46,7 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Directory", title: "Directory",
onBackPressed: () => Get.offNamed('/dashboard'), onBackPressed: () => Get.offNamed('/dashboard'),
projectName: " All Projects",
backgroundColor: appBarColor, backgroundColor: appBarColor,
), ),
body: Stack( body: Stack(
@ -73,6 +74,10 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
PillTabBar( PillTabBar(
controller: _tabController, controller: _tabController,
tabs: const ["Directory", "Notes"], tabs: const ["Directory", "Notes"],
icons: const [
Icons.people,
Icons.notes_outlined,
],
selectedColor: contentTheme.primary, selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600, unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary, indicatorColor: contentTheme.primary,

View File

@ -424,6 +424,8 @@ class _DirectoryViewState extends State<DirectoryView> with UIMixin {
child: controller.isLoading.value child: controller.isLoading.value
? ListView.separated( ? ListView.separated(
physics: const AlwaysScrollableScrollPhysics(), physics: const AlwaysScrollableScrollPhysics(),
padding: MySpacing.only(
left: 10, right: 10, top: 4, bottom: 80),
itemCount: 10, itemCount: 10,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, __) => itemBuilder: (_, __) =>

View File

@ -526,6 +526,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
label: MyText( label: MyText(
'Assign to Project', 'Assign to Project',
fontSize: 14, fontSize: 14,
color: Colors.white,
fontWeight: 500, fontWeight: 500,
), ),
); );
@ -538,7 +539,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
if (managers.isEmpty) return ''; if (managers.isEmpty) return '';
return managers return managers
.map((m) => .map((m) =>
'${(m.firstName ?? '').trim()} ${(m.lastName ?? '').trim()}'.trim()) '${(m.firstName ).trim()} ${(m.lastName ).trim()}'.trim())
.where((name) => name.isNotEmpty) .where((name) => name.isNotEmpty)
.join(', '); .join(', ');
} }

View File

@ -4,6 +4,7 @@ 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/view/document/user_document_screen.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart'; // <-- import PillTabBar
class EmployeeProfilePage extends StatefulWidget { class EmployeeProfilePage extends StatefulWidget {
final String employeeId; final String employeeId;
@ -16,14 +17,11 @@ class EmployeeProfilePage extends StatefulWidget {
class _EmployeeProfilePageState extends State<EmployeeProfilePage> class _EmployeeProfilePageState extends State<EmployeeProfilePage>
with SingleTickerProviderStateMixin, UIMixin { 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.
late TabController _tabController; late TabController _tabController;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// Initialize TabController with 2 tabs
_tabController = TabController(length: 2, vsync: this); _tabController = TabController(length: 2, vsync: this);
} }
@ -33,11 +31,8 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
super.dispose(); super.dispose();
} }
// --- No need for _buildSegmentedButton function anymore ---
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Accessing theme colors for consistency
final Color appBarColor = contentTheme.primary; final Color appBarColor = contentTheme.primary;
final Color primaryColor = contentTheme.primary; final Color primaryColor = contentTheme.primary;
@ -45,13 +40,13 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
backgroundColor: const Color(0xFFF1F1F1), backgroundColor: const Color(0xFFF1F1F1),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Employee Profile", title: "Employee Profile",
projectName: " All Projects",
onBackPressed: () => Get.back(), onBackPressed: () => Get.back(),
backgroundColor: appBarColor, backgroundColor: appBarColor,
), ),
body: Stack( body: Stack(
children: [ children: [
// === Gradient at the top behind AppBar + Toggle === // Gradient at the top behind AppBar + Toggle
// This container ensures the background color transitions nicely
Container( Container(
height: 50, height: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
@ -65,63 +60,20 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
), ),
), ),
), ),
// === Main Content Area ===
SafeArea( SafeArea(
top: false, top: false,
bottom: true, bottom: true,
child: Column( child: Column(
children: [ children: [
// 🛑 NEW: The Modern TabBar Implementation 🛑 PillTabBar(
Padding( controller: _tabController,
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0), tabs: const ["Details", "Documents"],
child: Container( icons: const [Icons.person, Icons.folder],
height: 48, // Define a specific height for the TabBar container selectedColor: primaryColor,
decoration: BoxDecoration( unselectedColor: Colors.grey.shade600,
color: Colors.white, indicatorColor: primaryColor,
borderRadius: BorderRadius.circular(24.0), // Rounded corners for a chip-like look height: 48,
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 (The Content) 🛑
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,
@ -144,4 +96,4 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
), ),
); );
} }
} }

View File

@ -7,9 +7,7 @@ import 'package:on_field_work/helpers/widgets/my_text.dart';
import 'package:on_field_work/model/employees/add_employee_bottom_sheet.dart'; import 'package:on_field_work/model/employees/add_employee_bottom_sheet.dart';
import 'package:on_field_work/controller/employee/employees_screen_controller.dart'; import 'package:on_field_work/controller/employee/employees_screen_controller.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart'; import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/controller/project_controller.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart'; import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
import 'package:on_field_work/model/employees/employee_model.dart';
import 'package:on_field_work/helpers/utils/launcher_utils.dart'; import 'package:on_field_work/helpers/utils/launcher_utils.dart';
import 'package:on_field_work/view/employees/assign_employee_bottom_sheet.dart'; import 'package:on_field_work/view/employees/assign_employee_bottom_sheet.dart';
import 'package:on_field_work/controller/permission_controller.dart'; import 'package:on_field_work/controller/permission_controller.dart';
@ -30,56 +28,27 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
late final EmployeesScreenController _employeeController; late final EmployeesScreenController _employeeController;
late final PermissionController _permissionController; late final PermissionController _permissionController;
final TextEditingController _searchController = TextEditingController(); final TextEditingController _searchController = TextEditingController();
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_employeeController = Get.put(EmployeesScreenController()); _employeeController = Get.put(EmployeesScreenController());
_permissionController = Get.put(PermissionController()); _permissionController = Get.put(PermissionController());
WidgetsBinding.instance.addPostFrameCallback((_) async { _searchController.addListener(() {
await _initEmployees(); _employeeController.searchEmployees(_searchController.text);
_searchController.addListener(() {
_filterEmployees(_searchController.text);
});
}); });
} }
Future<void> _initEmployees() async {
await _employeeController.fetchAllEmployees();
_filterEmployees(_searchController.text);
}
Future<void> _refreshEmployees() async { Future<void> _refreshEmployees() async {
try { try {
await _employeeController.fetchAllEmployees(); await _employeeController.fetchAllEmployees();
_filterEmployees(_searchController.text); _employeeController.searchEmployees(_searchController.text);
_employeeController.update(['employee_screen_controller']);
} catch (e, stackTrace) { } catch (e, stackTrace) {
debugPrint('Error refreshing employee data: $e'); debugPrint('Error refreshing employee data: $e');
debugPrintStack(stackTrace: stackTrace); debugPrintStack(stackTrace: stackTrace);
} }
} }
void _filterEmployees(String query) {
final employees = _employeeController.employees;
final searchQuery = query.toLowerCase();
final filtered = query.isEmpty
? List<EmployeeModel>.from(employees)
: employees
.where(
(e) =>
e.name.toLowerCase().contains(searchQuery) ||
e.email.toLowerCase().contains(searchQuery) ||
e.phoneNumber.toLowerCase().contains(searchQuery) ||
e.jobRole.toLowerCase().contains(searchQuery),
)
.toList();
filtered
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
_filteredEmployees.assignAll(filtered);
}
Future<void> _onAddNewEmployee() async { Future<void> _onAddNewEmployee() async {
final result = await showModalBottomSheet<Map<String, dynamic>>( final result = await showModalBottomSheet<Map<String, dynamic>>(
context: context, context: context,
@ -121,8 +90,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Employees", title: "Employees",
backgroundColor: appBarColor, backgroundColor: appBarColor,
projectName: Get.find<ProjectController>().selectedProject?.name ?? projectName: " All Projects",
'Select Project',
onBackPressed: () => Get.offNamed('/dashboard'), onBackPressed: () => Get.offNamed('/dashboard'),
), ),
body: Stack( body: Stack(
@ -144,35 +112,27 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
// Main content // Main content
SafeArea( SafeArea(
child: GetBuilder<EmployeesScreenController>( child: Obx(() {
init: _employeeController, return MyRefreshIndicator(
tag: 'employee_screen_controller', onRefresh: _refreshEmployees,
builder: (_) { child: SingleChildScrollView(
_filterEmployees(_searchController.text); physics: const AlwaysScrollableScrollPhysics(),
return MyRefreshIndicator( padding: const EdgeInsets.only(bottom: 40),
onRefresh: _refreshEmployees, child: Column(
child: SingleChildScrollView( crossAxisAlignment: CrossAxisAlignment.start,
physics: const AlwaysScrollableScrollPhysics(), children: [
padding: const EdgeInsets.only(bottom: 40), MySpacing.height(flexSpacing),
child: Column( _buildSearchField(),
crossAxisAlignment: CrossAxisAlignment.start, MySpacing.height(flexSpacing),
children: [ Padding(
MySpacing.height(flexSpacing), padding: MySpacing.x(flexSpacing),
Padding( child: _buildEmployeeList(),
padding: MySpacing.x(15), ),
child: _buildSearchField(), ],
),
MySpacing.height(flexSpacing),
Padding(
padding: MySpacing.x(flexSpacing),
child: _buildEmployeeList(),
),
],
),
), ),
); ),
}, );
), }),
), ),
], ],
), ),
@ -238,7 +198,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
size: 20, color: Colors.grey), size: 20, color: Colors.grey),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_filterEmployees('');
}, },
); );
}, },
@ -255,13 +214,11 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
borderSide: BorderSide(color: Colors.grey.shade300), borderSide: BorderSide(color: Colors.grey.shade300),
), ),
), ),
onChanged: (_) => _filterEmployees(_searchController.text),
), ),
), ),
), ),
MySpacing.width(10), MySpacing.width(10),
// Three dots menu (Manage Reporting)
Container( Container(
height: 35, height: 35,
width: 35, width: 35,
@ -277,10 +234,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)), borderRadius: BorderRadius.circular(5)),
itemBuilder: (context) { itemBuilder: (context) {
List<PopupMenuEntry<int>> menuItems = []; return [
// Section: Actions
menuItems.add(
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
height: 30, height: 30,
@ -290,10 +244,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
fontWeight: FontWeight.bold, color: Colors.grey), fontWeight: FontWeight.bold, color: Colors.grey),
), ),
), ),
);
// Manage Reporting option
menuItems.add(
PopupMenuItem<int>( PopupMenuItem<int>(
value: 1, value: 1,
child: Row( child: Row(
@ -317,9 +267,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
}); });
}, },
), ),
); ];
return menuItems;
}, },
), ),
), ),
@ -329,88 +277,85 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildEmployeeList() { Widget _buildEmployeeList() {
return Obx(() { if (_employeeController.isLoading.value) {
if (_employeeController.isLoading.value) {
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
itemCount: 8,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(),
);
}
final employees = _filteredEmployees;
if (employees.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 60),
child: Center(
child: MyText.bodySmall("No Employees Found",
fontWeight: 600, color: Colors.grey[700]),
),
);
}
return ListView.separated( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), physics: const NeverScrollableScrollPhysics(),
padding: MySpacing.only(bottom: 80), itemCount: 8,
itemCount: employees.length,
separatorBuilder: (_, __) => MySpacing.height(12), separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) { itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(),
final e = employees[index];
final names = e.name.trim().split(' ');
final firstName = names.first;
final lastName = names.length > 1 ? names.last : '';
return InkWell(
onTap: () => Get.to(() => EmployeeProfilePage(employeeId: e.id)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: firstName, lastName: lastName, size: 35),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(e.name,
fontWeight: 600, overflow: TextOverflow.ellipsis),
if (e.jobRole.isNotEmpty)
MyText.bodySmall(e.jobRole,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis),
MySpacing.height(8),
if (e.email.isNotEmpty && e.email != '-')
_buildLinkRow(
icon: Icons.email_outlined,
text: e.email,
onTap: () => LauncherUtils.launchEmail(e.email),
onLongPress: () => LauncherUtils.copyToClipboard(
e.email,
typeLabel: 'Email')),
if (e.email.isNotEmpty && e.email != '-')
MySpacing.height(6),
if (e.phoneNumber.isNotEmpty)
_buildLinkRow(
icon: Icons.phone_outlined,
text: e.phoneNumber,
onTap: () =>
LauncherUtils.launchPhone(e.phoneNumber),
onLongPress: () => LauncherUtils.copyToClipboard(
e.phoneNumber,
typeLabel: 'Phone')),
],
),
),
const Icon(Icons.arrow_forward_ios,
color: Colors.grey, size: 16),
],
),
);
},
); );
}); }
final employees = _employeeController.filteredEmployees;
if (employees.isEmpty) {
return Padding(
padding: const EdgeInsets.only(top: 60),
child: Center(
child: MyText.bodySmall("No Employees Found",
fontWeight: 600, color: Colors.grey[700]),
),
);
}
return ListView.separated(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
padding: MySpacing.only(bottom: 80),
itemCount: employees.length,
separatorBuilder: (_, __) => MySpacing.height(12),
itemBuilder: (_, index) {
final e = employees[index];
final names = e.name.trim().split(' ');
final firstName = names.first;
final lastName = names.length > 1 ? names.last : '';
return InkWell(
onTap: () => Get.to(() => EmployeeProfilePage(employeeId: e.id)),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(firstName: firstName, lastName: lastName, size: 35),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleSmall(e.name,
fontWeight: 600, overflow: TextOverflow.ellipsis),
if (e.jobRole.isNotEmpty)
MyText.bodySmall(e.jobRole,
color: Colors.grey[700],
overflow: TextOverflow.ellipsis),
MySpacing.height(8),
if (e.email.isNotEmpty && e.email != '-')
_buildLinkRow(
icon: Icons.email_outlined,
text: e.email,
onTap: () => LauncherUtils.launchEmail(e.email),
onLongPress: () => LauncherUtils.copyToClipboard(
e.email,
typeLabel: 'Email')),
if (e.email.isNotEmpty && e.email != '-')
MySpacing.height(6),
if (e.phoneNumber.isNotEmpty)
_buildLinkRow(
icon: Icons.phone_outlined,
text: e.phoneNumber,
onTap: () => LauncherUtils.launchPhone(e.phoneNumber),
onLongPress: () => LauncherUtils.copyToClipboard(
e.phoneNumber,
typeLabel: 'Phone')),
],
),
),
const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16),
],
),
);
},
);
} }
Widget _buildLinkRow({ Widget _buildLinkRow({

View File

@ -36,6 +36,10 @@ class _ManageReportingBottomSheetState
final TextEditingController _primaryController = TextEditingController(); final TextEditingController _primaryController = TextEditingController();
final TextEditingController _secondaryController = TextEditingController(); final TextEditingController _secondaryController = TextEditingController();
final FocusNode _mainEmployeeFocus = FocusNode();
final FocusNode _primaryFocus = FocusNode();
final FocusNode _secondaryFocus = FocusNode();
final RxList<EmployeeModel> _filteredPrimary = <EmployeeModel>[].obs; final RxList<EmployeeModel> _filteredPrimary = <EmployeeModel>[].obs;
final RxList<EmployeeModel> _filteredSecondary = <EmployeeModel>[].obs; final RxList<EmployeeModel> _filteredSecondary = <EmployeeModel>[].obs;
final RxList<EmployeeModel> _selectedPrimary = <EmployeeModel>[].obs; final RxList<EmployeeModel> _selectedPrimary = <EmployeeModel>[].obs;
@ -69,6 +73,10 @@ class _ManageReportingBottomSheetState
@override @override
void dispose() { void dispose() {
_mainEmployeeFocus.dispose();
_primaryFocus.dispose();
_secondaryFocus.dispose();
_primaryController.dispose(); _primaryController.dispose();
_secondaryController.dispose(); _secondaryController.dispose();
_selectEmployeeController.dispose(); _selectEmployeeController.dispose();
@ -368,6 +376,7 @@ class _ManageReportingBottomSheetState
_buildSearchSection( _buildSearchSection(
label: "Primary Reporting Manager*", label: "Primary Reporting Manager*",
controller: _primaryController, controller: _primaryController,
focusNode: _primaryFocus,
filteredList: _filteredPrimary, filteredList: _filteredPrimary,
selectedList: _selectedPrimary, selectedList: _selectedPrimary,
isPrimary: true, isPrimary: true,
@ -379,6 +388,7 @@ class _ManageReportingBottomSheetState
_buildSearchSection( _buildSearchSection(
label: "Secondary Reporting Manager", label: "Secondary Reporting Manager",
controller: _secondaryController, controller: _secondaryController,
focusNode: _secondaryFocus,
filteredList: _filteredSecondary, filteredList: _filteredSecondary,
selectedList: _selectedSecondary, selectedList: _selectedSecondary,
isPrimary: false, isPrimary: false,
@ -386,12 +396,13 @@ class _ManageReportingBottomSheetState
], ],
); );
// 🔥 WRAP EVERYTHING IN SAFEAREA + SCROLL + BOTTOM PADDING
final safeWrappedContent = SafeArea( final safeWrappedContent = SafeArea(
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.only( padding: EdgeInsets.only(
bottom: MediaQuery.of(context).viewPadding.bottom + 20, bottom: 8,
left: 16, right: 16, top: 8, left: 16,
right: 16,
top: 8,
), ),
child: content, child: content,
), ),
@ -417,7 +428,7 @@ class _ManageReportingBottomSheetState
isSubmitting: _isSubmitting, isSubmitting: _isSubmitting,
onCancel: _handleCancel, onCancel: _handleCancel,
onSubmit: _handleSubmit, onSubmit: _handleSubmit,
child: safeWrappedContent, child: safeWrappedContent,
); );
} }
@ -449,6 +460,7 @@ class _ManageReportingBottomSheetState
Widget _buildSearchSection({ Widget _buildSearchSection({
required String label, required String label,
required TextEditingController controller, required TextEditingController controller,
required FocusNode focusNode,
required RxList<EmployeeModel> filteredList, required RxList<EmployeeModel> filteredList,
required RxList<EmployeeModel> selectedList, required RxList<EmployeeModel> selectedList,
required bool isPrimary, required bool isPrimary,
@ -459,20 +471,10 @@ class _ManageReportingBottomSheetState
MyText.bodyMedium(label, fontWeight: 600), MyText.bodyMedium(label, fontWeight: 600),
MySpacing.height(8), MySpacing.height(8),
// Search field _searchBar(
TextField(
controller: controller, controller: controller,
decoration: InputDecoration( focusNode: focusNode,
hintText: "Type to search employees...", hint: "Type to search employees...",
isDense: true,
filled: true,
fillColor: Colors.grey[50],
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
), ),
// Dropdown suggestions // Dropdown suggestions
@ -567,19 +569,10 @@ class _ManageReportingBottomSheetState
children: [ children: [
MyText.bodyMedium("Select Employee *", fontWeight: 600), MyText.bodyMedium("Select Employee *", fontWeight: 600),
MySpacing.height(8), MySpacing.height(8),
TextField( _searchBar(
controller: _selectEmployeeController, controller: _selectEmployeeController,
decoration: InputDecoration( focusNode: _mainEmployeeFocus,
hintText: "Type to search employee...", hint: "Type to search employee...",
isDense: true,
filled: true,
fillColor: Colors.grey[50],
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(6),
borderSide: BorderSide(color: Colors.grey.shade300),
),
),
), ),
Obx(() { Obx(() {
if (_filteredEmployees.isEmpty) return const SizedBox.shrink(); if (_filteredEmployees.isEmpty) return const SizedBox.shrink();
@ -641,4 +634,55 @@ class _ManageReportingBottomSheetState
], ],
); );
} }
Widget _searchBar({
required TextEditingController controller,
required FocusNode focusNode,
required String hint,
}) {
return SizedBox(
height: 48,
child: TextField(
controller: controller,
focusNode: focusNode,
style: const TextStyle(fontSize: 14),
decoration: InputDecoration(
hintText: hint,
hintStyle: const TextStyle(
fontSize: 14,
color: Colors.grey,
),
filled: true,
fillColor: Colors.grey.shade100,
prefixIcon: const Padding(
padding: EdgeInsets.only(left: 12, right: 8),
child: Icon(Icons.search, size: 18, color: Colors.grey),
),
prefixIconConstraints: const BoxConstraints(
minWidth: 32,
minHeight: 32,
),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
enabledBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: BorderSide(color: Colors.grey.shade300),
),
focusedBorder: OutlineInputBorder(
borderRadius: BorderRadius.circular(12),
borderSide: const BorderSide(
color: Colors.blueAccent,
width: 1.5,
),
),
contentPadding:
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
isDense: true,
),
),
);
}
} }

View File

@ -11,14 +11,12 @@ import 'package:on_field_work/model/expense/comment_bottom_sheet.dart';
import 'package:on_field_work/model/expense/expense_detail_model.dart'; import 'package:on_field_work/model/expense/expense_detail_model.dart';
import 'package:on_field_work/model/expense/reimbursement_bottom_sheet.dart'; import 'package:on_field_work/model/expense/reimbursement_bottom_sheet.dart';
import 'package:on_field_work/controller/expense/add_expense_controller.dart'; 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:url_launcher/url_launcher.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.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/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.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_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.dart'; import 'package:on_field_work/helpers/widgets/my_text.dart';
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/model/employees/employee_info.dart';
import 'package:timeline_tile/timeline_tile.dart'; import 'package:timeline_tile/timeline_tile.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
@ -37,15 +35,14 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
final projectController = Get.find<ProjectController>(); final projectController = Get.find<ProjectController>();
final permissionController = Get.put(PermissionController()); final permissionController = Get.put(PermissionController());
EmployeeInfo? employeeInfo; // Removed local employeeInfo, canSubmit, and _checkedPermission
final RxBool canSubmit = false.obs;
bool _checkedPermission = false;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller = Get.put(ExpenseDetailController(), tag: widget.expenseId); controller = Get.put(ExpenseDetailController(), tag: widget.expenseId);
// EmployeeInfo loading and permission checking is now handled inside controller.init()
controller.init(widget.expenseId); controller.init(widget.expenseId);
_loadEmployeeInfo();
} }
@override @override
@ -54,271 +51,239 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
super.dispose(); super.dispose();
} }
void _loadEmployeeInfo() async { // Removed _loadEmployeeInfo and _checkPermissionToSubmit
final info = await LocalStorage.getEmployeeInfo();
employeeInfo = info;
}
void _checkPermissionToSubmit(ExpenseDetailModel expense) { @override
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary;
final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id; return Scaffold(
final nextStatusIds = expense.nextStatus.map((e) => e.id).toList(); backgroundColor: const Color(0xFFF7F7F7),
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId); appBar: CustomAppBar(
title: "Expense Details",
final result = isCreatedByCurrentUser && hasRequiredNextStatus; projectName: " All Projects",
backgroundColor: appBarColor,
logSafe( onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'),
'🐛 Checking submit permission:\n' ),
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n' body: Stack(
'🐛 - Expense created by ID: ${expense.createdBy.id}\n' children: [
'🐛 - Next Status IDs: $nextStatusIds\n' // Gradient behind content
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n' Container(
'🐛 - Final Permission Result: $result', height: kToolbarHeight + MediaQuery.of(context).padding.top,
level: LogLevel.debug, decoration: BoxDecoration(
); gradient: LinearGradient(
begin: Alignment.topCenter,
canSubmit.value = result; end: Alignment.bottomCenter,
} colors: [
appBarColor,
@override appBarColor.withOpacity(0.0),
Widget build(BuildContext context) { ],
final Color appBarColor = contentTheme.primary; ),
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),
],
), ),
), ),
),
// Main content // Main content
SafeArea( SafeArea(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton(); if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value; final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) { if (controller.errorMessage.isNotEmpty || expense == null) {
return Center(child: MyText.bodyMedium("No data to display.")); return Center(child: MyText.bodyMedium("No data to display."));
} }
WidgetsBinding.instance.addPostFrameCallback((_) { // Permission logic moved to controller (no need for postFrameCallback here)
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor( final statusColor = getExpenseStatusColor(
expense.status.name, expense.status.name,
colorCode: expense.status.color, colorCode: expense.status.color,
); );
final formattedAmount = formatExpenseAmount(expense.amount); final formattedAmount = formatExpenseAmount(expense.amount);
return MyRefreshIndicator( return MyRefreshIndicator(
onRefresh: () async { onRefresh: () async {
await controller.fetchExpenseDetails(); await controller.fetchExpenseDetails();
}, },
child: SingleChildScrollView( child: SingleChildScrollView(
padding: EdgeInsets.fromLTRB( padding: EdgeInsets.fromLTRB(
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom 12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
), child: Center(
child: Center( child: Container(
child: Container( constraints: const BoxConstraints(maxWidth: 520),
constraints: const BoxConstraints(maxWidth: 520), child: Card(
child: Card( shape: RoundedRectangleBorder(
shape: RoundedRectangleBorder( borderRadius: BorderRadius.circular(5)),
borderRadius: BorderRadius.circular(5)), elevation: 3,
elevation: 3, child: Padding(
child: Padding( padding: const EdgeInsets.symmetric(
padding: const EdgeInsets.symmetric( vertical: 14, horizontal: 14),
vertical: 14, horizontal: 14), child: Column(
child: Column( crossAxisAlignment: CrossAxisAlignment.start,
crossAxisAlignment: CrossAxisAlignment.start, children: [
children: [ // Header & Status
// Header & Status _InvoiceHeader(expense: expense),
_InvoiceHeader(expense: expense), const Divider(height: 30, thickness: 1.2),
const Divider(height: 30, thickness: 1.2),
// Activity Logs // Activity Logs
InvoiceLogs(logs: expense.expenseLogs), InvoiceLogs(logs: expense.expenseLogs),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Amount & Summary // Amount & Summary
Row( Row(
children: [ children: [
Column( Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment:
children: [ CrossAxisAlignment.start,
MyText.bodyMedium('Amount', fontWeight: 600), children: [
const SizedBox(height: 4), MyText.bodyMedium('Amount',
MyText.bodyLarge( fontWeight: 600),
formattedAmount, const SizedBox(height: 4),
fontWeight: 700, MyText.bodyLarge(
color: statusColor, formattedAmount,
), fontWeight: 700,
], color: statusColor,
), ),
const Spacer(), ],
if (expense.preApproved)
Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
),
child: MyText.bodySmall(
'Pre-Approved',
fontWeight: 600,
color: Colors.green,
),
), ),
], const Spacer(),
), if (expense.preApproved)
const Divider(height: 30, thickness: 1.2), Container(
padding: const EdgeInsets.symmetric(
horizontal: 10, vertical: 4),
decoration: BoxDecoration(
color: Colors.green.withOpacity(0.15),
borderRadius: BorderRadius.circular(5),
),
child: MyText.bodySmall(
'Pre-Approved',
fontWeight: 600,
color: Colors.green,
),
),
],
),
const Divider(height: 30, thickness: 1.2),
// Parties // Parties
_InvoicePartiesTable(expense: expense), _InvoicePartiesTable(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Expense Details // Expense Details
_InvoiceDetailsTable(expense: expense), _InvoiceDetailsTable(expense: expense),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Documents // Documents
_InvoiceDocuments(documents: expense.documents), _InvoiceDocuments(documents: expense.documents),
const Divider(height: 30, thickness: 1.2), const Divider(height: 30, thickness: 1.2),
// Totals // Totals
_InvoiceTotals( _InvoiceTotals(
expense: expense, expense: expense,
formattedAmount: formattedAmount, formattedAmount: formattedAmount,
statusColor: statusColor, statusColor: statusColor,
), ),
], ],
),
), ),
), ),
), ),
), ),
), ),
), );
); }),
}),
),
],
),
floatingActionButton: Obx(() {
if (controller.isLoading.value) return buildLoadingSkeleton();
final expense = controller.expense.value;
if (controller.errorMessage.isNotEmpty || expense == null) {
return const SizedBox.shrink();
}
if (!_checkedPermission) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
}
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(),
};
final addCtrl = Get.put(AddExpenseController());
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
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, floatingActionButton: Obx(() {
spacing: 10, final expense = controller.expense.value;
runSpacing: 10, if (controller.errorMessage.isNotEmpty || expense == null) {
children: expense.nextStatus.where((next) { return const SizedBox.shrink();
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7'; }
final rawPermissions = next.permissionIds; // Removed _checkedPermission and its logic
final parsedPermissions =
controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId; if (!ExpensePermissionHelper.canEditExpense(
final isCreatedByCurrentUser = controller.employeeInfo, // Use controller's employeeInfo
employeeInfo?.id == expense.createdBy.id; expense)) {
return const SizedBox.shrink();
}
if (isSubmitStatus) return isCreatedByCurrentUser; return FloatingActionButton.extended(
return permissionController.hasAnyPermission(parsedPermissions); onPressed: () async {
}).map((next) { final editData = {
return _statusButton(context, controller, expense, next); 'id': expense.id,
}).toList(), '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(),
};
final addCtrl = Get.put(AddExpenseController());
await addCtrl.loadMasterData();
addCtrl.populateFieldsForEdit(editData);
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 = controller.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, Widget _statusButton(BuildContext context, ExpenseDetailController controller,
ExpenseDetailModel expense, dynamic next) { ExpenseDetailModel expense, dynamic next) {

View File

@ -95,6 +95,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Expense & Reimbursement", title: "Expense & Reimbursement",
projectName: " All Projects",
backgroundColor: appBarColor, backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/finance'), onBackPressed: () => Get.toNamed('/dashboard/finance'),
), ),
@ -133,6 +134,10 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
PillTabBar( PillTabBar(
controller: _tabController, controller: _tabController,
tabs: const ["Current Month", "History"], tabs: const ["Current Month", "History"],
icons: const [
Icons.calendar_today,
Icons.history,
],
selectedColor: contentTheme.primary, selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600, unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary, indicatorColor: contentTheme.primary,

View File

@ -54,6 +54,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
backgroundColor: const Color(0xFFF5F5F5), backgroundColor: const Color(0xFFF5F5F5),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Advance Payments", title: "Advance Payments",
projectName: " All Projects",
onBackPressed: () => Get.offNamed('/dashboard/finance'), onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor, backgroundColor: appBarColor,
), ),

View File

@ -14,7 +14,6 @@ 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/model/dynamicMenu/dynamic_menu_model.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
class FinanceScreen extends StatefulWidget { class FinanceScreen extends StatefulWidget {
const FinanceScreen({super.key}); const FinanceScreen({super.key});
@ -59,7 +58,8 @@ class _FinanceScreenState extends State<FinanceScreen>
backgroundColor: const Color(0xFFF8F9FA), backgroundColor: const Color(0xFFF8F9FA),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Finance", title: "Finance",
onBackPressed: () => Get.offAllNamed( '/dashboard' ), projectName: " All Projects",
onBackPressed: () => Get.offAllNamed('/dashboard'),
backgroundColor: appBarColor, backgroundColor: appBarColor,
), ),
body: Stack( body: Stack(

View File

@ -114,6 +114,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Payment Request Details", title: "Payment Request Details",
projectName: " All Projects",
backgroundColor: appBarColor, backgroundColor: appBarColor,
), ),
body: Stack( body: Stack(
@ -217,7 +218,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (!_checkedPermission && request != null && employeeInfo != null) { if (!_checkedPermission && employeeInfo != null) {
_checkedPermission = true; _checkedPermission = true;
_checkPermissionToSubmit(request); _checkPermissionToSubmit(request);
} }

View File

@ -104,6 +104,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
backgroundColor: Colors.white, backgroundColor: Colors.white,
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Payment Requests", title: "Payment Requests",
projectName: " All Projects",
onBackPressed: () => Get.offNamed('/dashboard/finance'), onBackPressed: () => Get.offNamed('/dashboard/finance'),
backgroundColor: appBarColor, backgroundColor: appBarColor,
), ),
@ -142,6 +143,10 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
PillTabBar( PillTabBar(
controller: _tabController, controller: _tabController,
tabs: const ["Current Month", "History"], tabs: const ["Current Month", "History"],
icons: const [
Icons.calendar_today,
Icons.history,
],
selectedColor: contentTheme.primary, selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600, unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary, indicatorColor: contentTheme.primary,
@ -183,7 +188,13 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
return canCreate return canCreate
? FloatingActionButton.extended( ? FloatingActionButton.extended(
backgroundColor: contentTheme.primary, backgroundColor: contentTheme.primary,
onPressed: showPaymentRequestBottomSheet, onPressed: () {
showPaymentRequestBottomSheet(
onUpdated: () async {
await paymentController.fetchPaymentRequests();
},
);
},
icon: const Icon(Icons.add, color: Colors.white), icon: const Icon(Icons.add, color: Colors.white),
label: const Text( label: const Text(
"Create Payment Request", "Create Payment Request",

View File

@ -0,0 +1,306 @@
import 'package:flutter/material.dart';
import 'package:get/get.dart';
import 'package:on_field_work/controller/tenant/organization_selection_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/widgets/tenant/organization_selector.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/employees/multiple_select_bottomsheet.dart';
import 'package:on_field_work/controller/tenant/service_controller.dart';
import 'package:on_field_work/helpers/widgets/tenant/service_selector.dart';
import 'package:on_field_work/model/tenant/tenant_services_model.dart';
import 'package:on_field_work/helpers/services/api_service.dart';
import 'package:on_field_work/helpers/utils/base_bottom_sheet.dart';
import 'package:on_field_work/model/infra_project/assign_project_allocation_request.dart';
class JobRole {
final String id;
final String name;
JobRole({required this.id, required this.name});
factory JobRole.fromJson(Map<String, dynamic> json) {
return JobRole(
id: json['id'].toString(),
name: json['name'] ?? '',
);
}
}
class AssignEmployeeBottomSheet extends StatefulWidget {
final String projectId;
const AssignEmployeeBottomSheet({
super.key,
required this.projectId,
});
@override
State<AssignEmployeeBottomSheet> createState() =>
_AssignEmployeeBottomSheetState();
}
class _AssignEmployeeBottomSheetState extends State<AssignEmployeeBottomSheet> {
late final OrganizationController _organizationController;
late final ServiceController _serviceController;
final RxList<EmployeeModel> _selectedEmployees = <EmployeeModel>[].obs;
Organization? _selectedOrganization;
JobRole? _selectedRole;
final RxBool _isLoadingRoles = false.obs;
final RxList<JobRole> _roles = <JobRole>[].obs;
@override
void initState() {
super.initState();
_organizationController = Get.put(
OrganizationController(),
tag: 'assign_employee_org',
);
_serviceController = Get.put(
ServiceController(),
tag: 'assign_employee_service',
);
_organizationController.fetchOrganizations(widget.projectId);
_serviceController.fetchServices(widget.projectId);
_fetchRoles();
}
Future<void> _fetchRoles() async {
try {
_isLoadingRoles.value = true;
final res = await ApiService.getRoles();
if (res != null) {
_roles.assignAll(
res.map((e) => JobRole.fromJson(e)).toList(),
);
}
} finally {
_isLoadingRoles.value = false;
}
}
@override
void dispose() {
Get.delete<OrganizationController>(tag: 'assign_employee_org');
Get.delete<ServiceController>(tag: 'assign_employee_service');
super.dispose();
}
Future<void> _openEmployeeSelector() async {
final result = await showModalBottomSheet(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => EmployeeSelectionBottomSheet(
title: 'Select Employee(s)',
multipleSelection: true,
initiallySelected: _selectedEmployees.toList(),
),
);
if (result != null && result is List<EmployeeModel>) {
_selectedEmployees.assignAll(result);
}
}
void _handleAssign() async {
if (_selectedEmployees.isEmpty ||
_selectedRole == null ||
_serviceController.selectedService == null) {
Get.snackbar('Error', 'Please complete all selections');
return;
}
final allocations = _selectedEmployees
.map(
(e) => AssignProjectAllocationRequest(
employeeId: e.id,
projectId: widget.projectId,
jobRoleId: _selectedRole!.id,
serviceId: _serviceController.selectedService!.id,
status: true,
),
)
.toList();
final res = await ApiService.assignEmployeesToProject(
allocations: allocations,
);
if (res?.success == true) {
Navigator.of(context).pop(true);
} else {
Get.snackbar('Error', res?.message ?? 'Assignment failed');
}
}
BoxDecoration _boxDecoration() => BoxDecoration(
color: Colors.grey.shade100,
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey.shade300),
);
@override
Widget build(BuildContext context) {
return BaseBottomSheet(
title: 'Assign Employee',
submitText: 'Assign',
isSubmitting: false,
onCancel: () => Navigator.of(context).pop(),
onSubmit: _handleAssign,
child: Column(
mainAxisSize: MainAxisSize.min,
crossAxisAlignment: CrossAxisAlignment.start,
children: [
/// ORGANIZATION
MyText.labelMedium(
'Organization',
fontWeight: 600,
),
MySpacing.height(8),
Container(
height: 48,
decoration: _boxDecoration(),
child: OrganizationSelector(
controller: _organizationController,
height: 48,
onSelectionChanged: (Organization? org) async {
_selectedOrganization = org;
_selectedEmployees.clear();
_selectedRole = null;
_serviceController.clearSelection();
},
),
),
MySpacing.height(20),
/// EMPLOYEES
MyText.labelMedium(
'Employees',
fontWeight: 600,
),
MySpacing.height(8),
Obx(
() => InkWell(
onTap: _openEmployeeSelector,
child: _dropdownBox(
_selectedEmployees.isEmpty
? 'Select employee(s)'
: '${_selectedEmployees.length} employee(s) selected',
icon: Icons.search,
),
),
),
MySpacing.height(20),
/// SERVICE
MyText.labelMedium(
'Service',
fontWeight: 600,
),
MySpacing.height(8),
Container(
height: 48,
decoration: _boxDecoration(),
child: ServiceSelector(
controller: _serviceController,
height: 48,
onSelectionChanged: (Service? service) async {
_selectedRole = null;
},
),
),
MySpacing.height(20),
/// JOB ROLE
MyText.labelMedium(
'Job Role',
fontWeight: 600,
),
MySpacing.height(8),
Obx(() {
if (_isLoadingRoles.value) {
return _skeleton();
}
return PopupMenuButton<JobRole>(
offset: const Offset(0, 48),
onSelected: (role) {
_selectedRole = role;
setState(() {});
},
itemBuilder: (context) {
if (_roles.isEmpty) {
return const [
PopupMenuItem(
enabled: false,
child: Text('No roles found'),
),
];
}
return _roles
.map(
(r) => PopupMenuItem<JobRole>(
value: r,
child: Text(r.name),
),
)
.toList();
},
child: _dropdownBox(
_selectedRole?.name ?? 'Select role',
),
);
}),
],
),
);
}
Widget _dropdownBox(String text, {IconData icon = Icons.arrow_drop_down}) {
return Container(
height: 48,
padding: const EdgeInsets.symmetric(horizontal: 16),
decoration: _boxDecoration(),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
Expanded(
child: Text(
text,
overflow: TextOverflow.ellipsis,
style: const TextStyle(fontSize: 14),
),
),
Icon(icon, color: Colors.grey),
],
),
);
}
Widget _skeleton() {
return Container(
height: 48,
decoration: BoxDecoration(
color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(12),
),
);
}
}

View File

@ -10,11 +10,16 @@ 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_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.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/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.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/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_progress_report.dart';
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart'; import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
import 'package:on_field_work/model/infra_project/infra_team_list_model.dart';
import 'package:on_field_work/view/infraProject/assign_employee_infra_bottom_sheet.dart';
class InfraProjectDetailsScreen extends StatefulWidget { class InfraProjectDetailsScreen extends StatefulWidget {
final String projectId; final String projectId;
@ -36,51 +41,213 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
late final TabController _tabController; late final TabController _tabController;
final DynamicMenuController menuController = final DynamicMenuController menuController =
Get.find<DynamicMenuController>(); Get.find<DynamicMenuController>();
late final InfraProjectDetailsController controller;
final List<_InfraTab> _tabs = []; final List<_InfraTab> _tabs = [];
@override @override
void initState() { void initState() {
super.initState(); super.initState();
controller =
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
_prepareTabs(); _prepareTabs();
} }
void _prepareTabs() { void _prepareTabs() {
// Profile tab is always added _tabs.add(_InfraTab(
_tabs.add(_InfraTab(name: "Profile", view: _buildProfileTab())); name: "Profile",
icon: Icons.person,
view: _buildProfileTab(),
));
_tabs.add(_InfraTab(
name: "Team",
icon: Icons.group,
view: _buildTeamTab(),
));
final allowedMenu = menuController.menuItems.where((m) => m.available); final allowedMenu = menuController.menuItems.where((m) => m.available);
if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) { if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) {
_tabs.add( _tabs.add(_InfraTab(
_InfraTab( name: "Task Planning",
name: "Task Planning", icon: Icons.task,
view: DailyTaskPlanningScreen(projectId: widget.projectId), view: DailyTaskPlanningScreen(projectId: widget.projectId),
), ));
);
} }
if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) { if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) {
_tabs.add( _tabs.add(_InfraTab(
_InfraTab( name: "Task Progress",
name: "Task Progress", icon: Icons.trending_up,
view: DailyProgressReportScreen(projectId: widget.projectId), view: DailyProgressReportScreen(projectId: widget.projectId),
), ));
);
} }
_tabController = TabController(length: _tabs.length, vsync: this); _tabController = TabController(length: _tabs.length, vsync: this);
} }
void _openAssignEmployeeBottomSheet() async {
final result = await showModalBottomSheet<bool>(
context: context,
isScrollControlled: true,
backgroundColor: Colors.transparent,
builder: (_) => AssignEmployeeBottomSheet(
projectId: widget.projectId,
),
);
if (result == true) {
controller.fetchProjectTeamList();
Get.snackbar('Success', 'Employee assigned successfully');
}
}
@override @override
void dispose() { void dispose() {
_tabController.dispose(); _tabController.dispose();
super.dispose(); super.dispose();
} }
Widget _buildProfileTab() { Widget _buildTeamTab() {
final controller = return Obx(() {
Get.put(InfraProjectDetailsController(projectId: widget.projectId)); if (controller.teamLoading.value) {
return const Center(child: CircularProgressIndicator());
}
if (controller.teamErrorMessage.isNotEmpty) {
return Center(
child: MyText.bodyMedium(controller.teamErrorMessage.value),
);
}
if (controller.teamList.isEmpty) {
return const Center(
child: Text("No team members allocated to this project."),
);
}
final roleGroups = controller.groupedTeamByRole;
final sortedRoleEntries = roleGroups.entries.toList()
..sort((a, b) {
final aName = (a.value.isNotEmpty ? a.value.first.jobRoleName : '')
.toLowerCase();
final bName = (b.value.isNotEmpty ? b.value.first.jobRoleName : '')
.toLowerCase();
return aName.compareTo(bName);
});
return MyRefreshIndicator(
onRefresh: controller.fetchProjectTeamList,
backgroundColor: Colors.indigo,
color: Colors.white,
child: ListView.separated(
padding: const EdgeInsets.all(12),
itemCount: sortedRoleEntries.length,
separatorBuilder: (_, __) => const SizedBox(height: 12),
itemBuilder: (context, index) {
final teamMembers = sortedRoleEntries[index].value;
return _buildRoleCard(teamMembers);
},
),
);
});
}
Widget _buildRoleCard(List<ProjectAllocation> teamMembers) {
teamMembers.sort((a, b) {
final aName = ("${a.firstName} ${a.lastName}").trim().toLowerCase();
final bName = ("${b.firstName} ${b.lastName}").trim().toLowerCase();
return aName.compareTo(bName);
});
final String roleName =
(teamMembers.isNotEmpty ? (teamMembers.first.jobRoleName) : '').trim();
return Card(
elevation: 3,
shadowColor: Colors.black26,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(8),
),
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// TOP: Job Role name
if (roleName.isNotEmpty) ...[
MyText.bodyLarge(
roleName,
fontWeight: 700,
),
const Divider(height: 20),
] else
const Divider(height: 20),
// Team members list
...teamMembers.map((allocation) {
return InkWell(
onTap: () {
Get.to(
() => EmployeeProfilePage(
employeeId: allocation.employeeId,
),
);
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Avatar(
firstName: allocation.firstName,
lastName: allocation.lastName,
size: 32,
),
MySpacing.width(12),
Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
"${allocation.firstName} ${allocation.lastName}",
fontWeight: 600,
),
MyText.bodySmall(
allocation.serviceName.isNotEmpty
? "Service: ${allocation.serviceName}"
: "No Service Assigned",
color: Colors.grey[700],
),
],
),
),
Column(
crossAxisAlignment: CrossAxisAlignment.end,
children: [
MyText.bodySmall(
"Allocated",
color: Colors.grey.shade500,
),
MyText.bodySmall(
DateFormat('d MMM yyyy').format(
DateTime.parse(allocation.allocationDate),
),
fontWeight: 600,
),
],
),
],
),
),
);
}).toList(),
],
),
),
);
}
Widget _buildProfileTab() {
return Obx(() { return Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@ -153,35 +320,40 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
titleIcon: Icons.info_outline, titleIcon: Icons.info_outline,
children: [ children: [
_buildDetailRow( _buildDetailRow(
icon: Icons.location_on_outlined, icon: Icons.location_on_outlined,
label: 'Address', label: 'Address',
value: data.projectAddress ?? "-"), value: data.projectAddress ?? "-",
),
_buildDetailRow( _buildDetailRow(
icon: Icons.calendar_today_outlined, icon: Icons.calendar_today_outlined,
label: 'Start Date', label: 'Start Date',
value: data.startDate != null value: data.startDate != null
? DateFormat('d/M/yyyy').format(data.startDate!) ? DateFormat('d/M/yyyy').format(data.startDate!)
: "-"), : "-",
),
_buildDetailRow( _buildDetailRow(
icon: Icons.calendar_today_outlined, icon: Icons.calendar_today_outlined,
label: 'End Date', label: 'End Date',
value: data.endDate != null value: data.endDate != null
? DateFormat('d/M/yyyy').format(data.endDate!) ? DateFormat('d/M/yyyy').format(data.endDate!)
: "-"), : "-",
),
_buildDetailRow( _buildDetailRow(
icon: Icons.flag_outlined, icon: Icons.flag_outlined,
label: 'Status', label: 'Status',
value: data.projectStatus?.status ?? "-"), value: data.projectStatus?.status ?? "-",
),
_buildDetailRow( _buildDetailRow(
icon: Icons.person_outline, icon: Icons.person_outline,
label: 'Contact Person', label: 'Contact Person',
value: data.contactPerson ?? "-", value: data.contactPerson ?? "-",
isActionable: true, isActionable: true,
onTap: () { onTap: () {
if (data.contactPerson != null) { if (data.contactPerson != null) {
LauncherUtils.launchPhone(data.contactPerson!); LauncherUtils.launchPhone(data.contactPerson!);
} }
}), },
),
], ],
); );
} }
@ -192,22 +364,24 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
titleIcon: Icons.business_outlined, titleIcon: Icons.business_outlined,
children: [ children: [
_buildDetailRow( _buildDetailRow(
icon: Icons.person_outline, icon: Icons.person_outline,
label: 'Name', label: 'Name',
value: promoter.name ?? "-"), value: promoter.name ?? "-",
),
_buildDetailRow( _buildDetailRow(
icon: Icons.phone_outlined, icon: Icons.phone_outlined,
label: 'Contact', label: 'Contact',
value: promoter.contactNumber ?? "-", value: promoter.contactNumber ?? "-",
isActionable: true, isActionable: true,
onTap: () => onTap: () => LauncherUtils.launchPhone(promoter.contactNumber ?? ""),
LauncherUtils.launchPhone(promoter.contactNumber ?? "")), ),
_buildDetailRow( _buildDetailRow(
icon: Icons.email_outlined, icon: Icons.email_outlined,
label: 'Email', label: 'Email',
value: promoter.email ?? "-", value: promoter.email ?? "-",
isActionable: true, isActionable: true,
onTap: () => LauncherUtils.launchEmail(promoter.email ?? "")), onTap: () => LauncherUtils.launchEmail(promoter.email ?? ""),
),
], ],
); );
} }
@ -218,19 +392,24 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
titleIcon: Icons.engineering_outlined, titleIcon: Icons.engineering_outlined,
children: [ children: [
_buildDetailRow( _buildDetailRow(
icon: Icons.person_outline, label: 'Name', value: pmc.name ?? "-"), icon: Icons.person_outline,
label: 'Name',
value: pmc.name ?? "-",
),
_buildDetailRow( _buildDetailRow(
icon: Icons.phone_outlined, icon: Icons.phone_outlined,
label: 'Contact', label: 'Contact',
value: pmc.contactNumber ?? "-", value: pmc.contactNumber ?? "-",
isActionable: true, isActionable: true,
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? "")), onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? ""),
),
_buildDetailRow( _buildDetailRow(
icon: Icons.email_outlined, icon: Icons.email_outlined,
label: 'Email', label: 'Email',
value: pmc.email ?? "-", value: pmc.email ?? "-",
isActionable: true, isActionable: true,
onTap: () => LauncherUtils.launchEmail(pmc.email ?? "")), onTap: () => LauncherUtils.launchEmail(pmc.email ?? ""),
),
], ],
); );
} }
@ -251,7 +430,9 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(8), child: Icon(icon, size: 20)), padding: const EdgeInsets.all(8),
child: Icon(icon, size: 20),
),
MySpacing.width(16), MySpacing.width(16),
Expanded( Expanded(
child: Column( child: Column(
@ -329,6 +510,19 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
projectName: widget.projectName, projectName: widget.projectName,
backgroundColor: appBarColor, backgroundColor: appBarColor,
), ),
floatingActionButton: FloatingActionButton.extended(
onPressed: () {
_openAssignEmployeeBottomSheet();
},
backgroundColor: contentTheme.primary,
icon: const Icon(Icons.person_add),
label: MyText(
'Assign Employee',
fontSize: 14,
color: Colors.white,
fontWeight: 500,
),
),
body: Stack( body: Stack(
children: [ children: [
Container( Container(
@ -349,6 +543,7 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
PillTabBar( PillTabBar(
controller: _tabController, controller: _tabController,
tabs: _tabs.map((e) => e.name).toList(), tabs: _tabs.map((e) => e.name).toList(),
icons: _tabs.map((e) => e.icon).toList(),
selectedColor: contentTheme.primary, selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600, unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary, indicatorColor: contentTheme.primary,
@ -371,7 +566,12 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
/// INTERNAL MODEL /// INTERNAL MODEL
class _InfraTab { class _InfraTab {
final String name; final String name;
final IconData icon;
final Widget view; final Widget view;
_InfraTab({required this.name, required this.view}); _InfraTab({
required this.name,
required this.icon,
required this.view,
});
} }

View File

@ -11,6 +11,7 @@ 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/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart'; import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class InfraProjectScreen extends StatefulWidget { class InfraProjectScreen extends StatefulWidget {
const InfraProjectScreen({super.key}); const InfraProjectScreen({super.key});
@ -245,7 +246,7 @@ class _InfraProjectScreenState extends State<InfraProjectScreen> with UIMixin {
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return Center(child: SkeletonLoaders.serviceProjectListSkeletonLoader());
} }
final projects = controller.filteredProjects; final projects = controller.filteredProjects;

View File

@ -8,7 +8,7 @@ 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/helpers/services/api_endpoints.dart';
import 'package:on_field_work/images.dart'; import 'package:on_field_work/images.dart';
import 'package:on_field_work/view/layouts/user_profile_right_bar.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/services/auth_service.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart'; import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class Layout extends StatefulWidget { class Layout extends StatefulWidget {
@ -24,7 +24,6 @@ class Layout extends StatefulWidget {
class _LayoutState extends State<Layout> with UIMixin { class _LayoutState extends State<Layout> with UIMixin {
final LayoutController controller = LayoutController(); final LayoutController controller = LayoutController();
final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo(); final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo();
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
bool hasMpin = true; bool hasMpin = true;
@ -46,72 +45,69 @@ class _LayoutState extends State<Layout> with UIMixin {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return MyResponsive(builder: (context, _, screenMT) { return MyResponsive(builder: (context, _, screenMT) {
return GetBuilder( return GetBuilder<LayoutController>(
init: controller, init: controller,
builder: (_) { builder: (_) {
return (screenMT.isMobile || screenMT.isTablet) return _buildScaffold(context);
? _buildScaffold(context, isMobile: true)
: _buildScaffold(context);
}, },
); );
}); });
} }
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) { Widget _buildScaffold(BuildContext context) {
final primaryColor = contentTheme.primary; final primaryColor = contentTheme.primary;
return Scaffold( return Scaffold(
key: controller.scaffoldKey, key: controller.scaffoldKey,
endDrawer: const UserProfileBar(), endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton, floatingActionButton: widget.floatingActionButton,
body: Column( body: Column(
children: [ children: [
// Solid primary background area Container(
Container( width: double.infinity,
color: primaryColor,
child: _buildHeaderContent(),
),
Expanded(
child: Container(
width: double.infinity, width: double.infinity,
color: primaryColor, decoration: BoxDecoration(
child: _buildHeaderContent(isMobile), gradient: LinearGradient(
), begin: Alignment.topCenter,
Expanded( end: Alignment.bottomCenter,
child: Container( colors: [
width: double.infinity, primaryColor,
decoration: BoxDecoration( primaryColor.withOpacity(0.7),
gradient: LinearGradient( primaryColor.withOpacity(0.0),
begin: Alignment.topCenter, ],
end: Alignment.bottomCenter, stops: const [0.0, 0.1, 0.3],
colors: [
primaryColor,
primaryColor.withOpacity(0.7),
primaryColor.withOpacity(0.0),
],
stops: const [0.0, 0.1, 0.3],
),
), ),
child: SafeArea( ),
top: false, child: SafeArea(
child: GestureDetector( top: false,
behavior: HitTestBehavior.translucent, child: GestureDetector(
onTap: () {}, behavior: HitTestBehavior.translucent,
child: SingleChildScrollView( onTap: () =>
key: controller.scrollKey, FocusScope.of(context).unfocus(),
padding: EdgeInsets.zero, child: widget.child ??
child: widget.child, const SizedBox.shrink(),
),
),
), ),
), ),
), ),
], ),
)); ],
),
);
} }
Widget _buildHeaderContent(bool isMobile) { Widget _buildHeaderContent() {
final selectedTenant = TenantService.currentTenant; final selectedTenant = AuthService.currentTenant;
final bool isBeta = ApiEndpoints.baseUrl.contains("stage");
return Padding( return Padding(
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0), padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
child: Container( child: Container(
margin: const EdgeInsets.only(bottom: 18), margin: const EdgeInsets.only(bottom: 10),
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -139,7 +135,7 @@ class _LayoutState extends State<Layout> with UIMixin {
), ),
// Beta badge // Beta badge
if (ApiEndpoints.baseUrl.contains("stage")) if (isBeta)
Positioned( Positioned(
bottom: 0, bottom: 0,
left: 0, left: 0,

View File

@ -11,7 +11,7 @@ import 'package:on_field_work/helpers/widgets/avatar.dart';
import 'package:on_field_work/model/employees/employee_info.dart'; import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/controller/auth/mpin_controller.dart'; import 'package:on_field_work/controller/auth/mpin_controller.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart'; import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart'; import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/view/tenant/tenant_selection_screen.dart'; import 'package:on_field_work/view/tenant/tenant_selection_screen.dart';
import 'package:on_field_work/controller/tenant/tenant_switch_controller.dart'; import 'package:on_field_work/controller/tenant/tenant_switch_controller.dart';
import 'package:on_field_work/helpers/theme/theme_editor_widget.dart'; import 'package:on_field_work/helpers/theme/theme_editor_widget.dart';
@ -285,7 +285,7 @@ class _UserProfileBarState extends State<UserProfileBar>
); );
} }
final selectedTenant = TenantService.currentTenant; final selectedTenant = AuthService.currentTenant;
final sortedTenants = List.of(tenants); final sortedTenants = List.of(tenants);
if (selectedTenant != null) { if (selectedTenant != null) {

View File

@ -0,0 +1,291 @@
import 'package:flutter/material.dart';
import 'package:url_launcher/url_launcher.dart';
import 'package:flutter_in_store_app_version_checker/flutter_in_store_app_version_checker.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/images.dart';
import 'package:on_field_work/helpers/widgets/wave_background.dart';
class MandatoryUpdateScreen extends StatefulWidget {
final String newVersion;
final InStoreAppVersionCheckerResult? updateResult;
const MandatoryUpdateScreen({
super.key,
required this.newVersion,
this.updateResult,
});
@override
State<MandatoryUpdateScreen> createState() => _MandatoryUpdateScreenState();
}
class _MandatoryUpdateScreenState extends State<MandatoryUpdateScreen>
with SingleTickerProviderStateMixin, UIMixin {
late AnimationController _controller;
late Animation<double> _logoAnimation;
static const double _kMaxContentWidth = 480.0;
Color get _primaryColor => contentTheme.primary;
@override
void initState() {
super.initState();
_controller = AnimationController(
vsync: this,
duration: const Duration(milliseconds: 800),
);
_logoAnimation = CurvedAnimation(
parent: _controller,
curve: Curves.elasticOut,
);
_controller.forward();
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
Future<void> _launchStoreUrl() async {
final url = widget.updateResult?.appURL;
if (url != null && url.isNotEmpty) {
final uri = Uri.parse(url);
try {
if (await canLaunchUrl(uri)) {
await launchUrl(uri, mode: LaunchMode.externalApplication);
} else {
logSafe("Could not launch store URL: $url");
}
} catch (e, stack) {
logSafe(
"Error launching store URL: $url",
error: e,
stackTrace: stack,
level: LogLevel.error,
);
}
}
}
@override
Widget build(BuildContext context) {
return Directionality(
textDirection: TextDirection.ltr,
child: Scaffold(
body: Stack(
children: [
RedWaveBackground(brandRed: _primaryColor),
SafeArea(
child: Center(
child: ConstrainedBox(
constraints:
const BoxConstraints(maxWidth: _kMaxContentWidth),
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const SizedBox(height: 32),
ScaleTransition(
scale: _logoAnimation,
child: Container(
width: 100,
height: 100,
decoration: BoxDecoration(
color: Colors.white,
shape: BoxShape.circle,
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.1),
blurRadius: 20,
offset: const Offset(0, 8),
),
],
),
padding: const EdgeInsets.all(20),
child: Image.asset(Images.logoDark),
),
),
const SizedBox(height: 32),
Container(
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(16),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.05),
blurRadius: 10,
offset: const Offset(0, 5),
),
],
),
padding: const EdgeInsets.all(32),
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
children: [
// Title
Text(
"Update Required",
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.headlineMedium
?.copyWith(
fontWeight: FontWeight.bold,
color: Colors.black87,
),
),
const SizedBox(height: 12),
// Subtitle/Message
Text(
"A mandatory update (version ${widget.newVersion}) is available to continue using the application. Please update now for uninterrupted access.",
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodyLarge
?.copyWith(
color: Colors.black54,
height: 1.4,
),
),
const SizedBox(height: 32),
// Prominent Action Button
ElevatedButton.icon(
onPressed: _launchStoreUrl,
icon: const Icon(
Icons.system_update_alt,
color: Colors.white,
),
label: Text(
"UPDATE NOW",
style: Theme.of(context)
.textTheme
.labelLarge
?.copyWith(
fontWeight: FontWeight.w700,
color: Colors.white,
fontSize: 16,
),
),
style: ElevatedButton.styleFrom(
padding:
const EdgeInsets.symmetric(vertical: 18),
backgroundColor: _primaryColor,
shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(10),
),
elevation: 5,
),
),
const SizedBox(height: 32),
// Why Update Section
Text(
"Why updating is important:",
textAlign: TextAlign.start,
style: Theme.of(context)
.textTheme
.titleMedium
?.copyWith(
fontWeight: FontWeight.w600,
color: Colors.black87,
),
),
const SizedBox(height: 12),
const Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
BulletPoint(
text:
"Access new features and improvements"),
BulletPoint(
text:
"Fix critical bugs and security issues"),
BulletPoint(
text:
"Ensure smooth app performance and stability"),
BulletPoint(
text:
"Stay compatible with latest operating system and services"),
],
),
const SizedBox(height: 12),
Text(
"Thank you for keeping your app up to date!",
textAlign: TextAlign.center,
style: Theme.of(context)
.textTheme
.bodySmall
?.copyWith(
color: Colors.black45,
),
),
],
),
),
],
),
),
),
),
),
],
),
),
);
}
}
class BulletPoint extends StatelessWidget {
final String text;
final Color bulletColor;
const BulletPoint({
super.key,
required this.text,
this.bulletColor = const Color(0xFF555555),
});
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.only(bottom: 8),
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Padding(
padding: const EdgeInsets.only(right: 8, top: 4),
child: Icon(
Icons.circle,
size: 6,
color: bulletColor,
),
),
Expanded(
child: Text(
text,
style: Theme.of(context).textTheme.bodyMedium?.copyWith(
color: Colors.black87,
height: 1.4,
),
),
),
],
),
);
}
}

View File

@ -3,8 +3,9 @@ import 'package:get/get.dart';
import 'package:flutter_localizations/flutter_localizations.dart'; import 'package:flutter_localizations/flutter_localizations.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:flutter_in_store_app_version_checker/flutter_in_store_app_version_checker.dart';
import 'package:on_field_work/helpers/services/app_logger.dart'; import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/extensions/app_localization_delegate.dart';
import 'package:on_field_work/helpers/services/localizations/language.dart'; import 'package:on_field_work/helpers/services/localizations/language.dart';
import 'package:on_field_work/helpers/services/navigation_services.dart'; import 'package:on_field_work/helpers/services/navigation_services.dart';
import 'package:on_field_work/helpers/services/storage/local_storage.dart'; import 'package:on_field_work/helpers/services/storage/local_storage.dart';
@ -13,84 +14,120 @@ import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:on_field_work/helpers/theme/app_notifier.dart'; import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:on_field_work/routes.dart'; import 'package:on_field_work/routes.dart';
class MyApp extends StatelessWidget { import 'package:on_field_work/view/mandatory_update_screen.dart';
final bool isOffline;
class MyApp extends StatefulWidget {
final bool isOffline;
const MyApp({super.key, required this.isOffline}); const MyApp({super.key, required this.isOffline});
@override
State<MyApp> createState() => _MyAppState();
}
class _MyAppState extends State<MyApp> {
bool _needsUpdate = false;
String? _newVersion;
InStoreAppVersionCheckerResult? _updateResult;
final InStoreAppVersionChecker _checker = InStoreAppVersionChecker(
androidStore: AndroidStore.googlePlayStore,
);
@override
void initState() {
super.initState();
_checkVersion();
}
/// -------------------------
/// Version Check
/// -------------------------
Future<void> _checkVersion() async {
try {
final result = await _checker.checkUpdate();
_updateResult = result;
logSafe("Version Check initiated...");
logSafe("Current App Version: ${_checker.currentVersion}");
logSafe("Result canUpdate: ${result.canUpdate}");
logSafe("Result newVersion: ${result.newVersion}");
logSafe("Result appURL: ${result.appURL}");
if (result.canUpdate) {
setState(() {
_needsUpdate = true;
_newVersion = result.newVersion ?? "";
});
logSafe("New version available → $_newVersion");
}
if (result.errorMessage != null) {
logSafe("VersionChecker Error: ${result.errorMessage}");
}
} catch (e, stack) {
logSafe(
"Version check exception",
error: e,
stackTrace: stack,
level: LogLevel.error,
);
}
}
/// -------------------------
/// Initial Route Logic
/// -------------------------
Future<String> _getInitialRoute() async { Future<String> _getInitialRoute() async {
try { try {
final token = LocalStorage.getJwtToken(); final token = LocalStorage.getJwtToken();
if (token == null || token.isEmpty) { if (token == null || token.isEmpty) {
logSafe("User not logged in. Routing to /auth/login-option");
return "/auth/login-option"; return "/auth/login-option";
} }
final bool hasMpin = LocalStorage.getIsMpin(); if (LocalStorage.getIsMpin()) {
if (hasMpin) {
await LocalStorage.setBool("mpin_verified", false); await LocalStorage.setBool("mpin_verified", false);
logSafe("Routing to /auth/mpin-auth");
return "/auth/mpin-auth"; return "/auth/mpin-auth";
} }
logSafe("No MPIN. Routing to /dashboard");
return "/dashboard"; return "/dashboard";
} catch (e, stacktrace) { } catch (e, stack) {
logSafe("Error determining initial route", logSafe(
level: LogLevel.error, error: e, stackTrace: stacktrace); "Initial route ERROR",
error: e,
stackTrace: stack,
level: LogLevel.error,
);
return "/auth/login-option"; return "/auth/login-option";
} }
} }
// REVISED: Helper Widget to show a full-screen, well-designed offline status /// -------------------------
Widget _buildConnectivityOverlay(BuildContext context) { /// Offline Overlay (Blocking)
// If not offline, return an empty widget. /// -------------------------
if (!isOffline) return const SizedBox.shrink(); Widget _buildOfflineOverlay() {
// Otherwise, return a full-screen overlay.
return Directionality( return Directionality(
textDirection: AppTheme.textDirection, textDirection: AppTheme.textDirection,
child: Scaffold( child: Scaffold(
backgroundColor: backgroundColor: Colors.grey.shade200,
Colors.grey.shade100, // Light background for the offline state
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon( Icon(Icons.cloud_off, size: 100, color: Colors.red.shade600),
Icons.cloud_off, const SizedBox(height: 20),
color: Colors.red.shade700, // Prominent color
size: 100,
),
const SizedBox(height: 24),
const Text( const Text(
"You Are Offline", "You Are Offline",
style: TextStyle( style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
), ),
const SizedBox(height: 8), const SizedBox(height: 10),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 40.0), padding: EdgeInsets.symmetric(horizontal: 32),
child: Text( child: Text(
"Please check your internet connection and try again.", "Please check your internet connection and try again.",
textAlign: TextAlign.center, textAlign: TextAlign.center,
style: TextStyle( style: TextStyle(fontSize: 16, color: Colors.black54),
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"),
// ),
], ],
), ),
), ),
@ -98,21 +135,28 @@ class MyApp extends StatelessWidget {
); );
} }
/// -------------------------
/// Build
/// -------------------------
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
if (_needsUpdate && !widget.isOffline) {
// 4. USE THE NEW WIDGET HERE
return MandatoryUpdateScreen(
newVersion: _newVersion ?? "",
updateResult: _updateResult,
);
}
if (widget.isOffline) {
return _buildOfflineOverlay();
}
return Consumer<AppNotifier>( return Consumer<AppNotifier>(
builder: (_, notifier, __) { builder: (_, notifier, __) {
return FutureBuilder<String>( return FutureBuilder<String>(
future: _getInitialRoute(), future: _getInitialRoute(),
builder: (context, snapshot) { builder: (context, snapshot) {
if (snapshot.hasError) {
logSafe("FutureBuilder snapshot error",
level: LogLevel.error, error: snapshot.error);
return const MaterialApp(
home: Center(child: Text("Error determining route")),
);
}
if (!snapshot.hasData) { if (!snapshot.hasData) {
return const MaterialApp( return const MaterialApp(
home: Center(child: CircularProgressIndicator()), home: Center(child: CircularProgressIndicator()),
@ -127,29 +171,19 @@ class MyApp extends StatelessWidget {
navigatorKey: NavigationService.navigatorKey, navigatorKey: NavigationService.navigatorKey,
initialRoute: snapshot.data!, initialRoute: snapshot.data!,
getPages: getPageRoute(), getPages: getPageRoute(),
builder: (context, child) { supportedLocales: Language.getLocales(),
NavigationService.registerContext(context); localizationsDelegates: const [
// 💡 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),
],
);
},
localizationsDelegates: [
AppLocalizationsDelegate(context),
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
supportedLocales: Language.getLocales(), builder: (context, child) {
NavigationService.registerContext(context);
return Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
);
},
); );
}, },
); );

View File

@ -14,6 +14,8 @@ 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/view/service_project/jobs_tab.dart';
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart'; import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart'; import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class ServiceProjectDetailsScreen extends StatefulWidget { class ServiceProjectDetailsScreen extends StatefulWidget {
final String projectId; final String projectId;
@ -332,7 +334,8 @@ class _ServiceProjectDetailsScreenState
Widget _buildTeamsTab() { Widget _buildTeamsTab() {
return Obx(() { return Obx(() {
if (controller.isTeamLoading.value) { if (controller.isTeamLoading.value) {
return const Center(child: CircularProgressIndicator()); return Center(
child: SkeletonLoaders.serviceProjectListSkeletonLoader());
} }
if (controller.teamErrorMessage.value.isNotEmpty && if (controller.teamErrorMessage.value.isNotEmpty &&
@ -385,36 +388,44 @@ class _ServiceProjectDetailsScreenState
const Divider(height: 20, thickness: 1), const Divider(height: 20, thickness: 1),
// List of team members inside this role card // List of team members inside this role card
...teamMembers.map((team) { ...teamMembers.map((team) {
return Padding( return InkWell(
padding: const EdgeInsets.symmetric(vertical: 8), onTap: () {
child: Row( // NAVIGATION TO EMPLOYEE DETAILS SCREEN
children: [ Get.to(() => EmployeeProfilePage(
Avatar( employeeId: team.employee.id,
firstName: team.employee.firstName, ));
lastName: team.employee.lastName, },
size: 32, child: Padding(
imageUrl: padding: const EdgeInsets.symmetric(vertical: 8),
(team.employee.photo?.isNotEmpty ?? false) child: Row(
? team.employee.photo children: [
: null, Avatar(
), firstName: team.employee.firstName,
MySpacing.width(12), lastName: team.employee.lastName,
Expanded( size: 32,
child: Column( imageUrl:
crossAxisAlignment: CrossAxisAlignment.start, (team.employee.photo?.isNotEmpty ?? false)
children: [ ? team.employee.photo
MyText.titleMedium( : null,
"${team.employee.firstName} ${team.employee.lastName}",
fontWeight: 600,
),
MyText.bodySmall(
"Status: ${team.isActive ? 'Active' : 'Inactive'}",
color: Colors.grey[700],
),
],
), ),
), MySpacing.width(12),
], Expanded(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
MyText.titleMedium(
"${team.employee.firstName} ${team.employee.lastName}",
fontWeight: 600,
),
MyText.bodySmall(
"Status: ${team.isActive ? 'Active' : 'Inactive'}",
color: Colors.grey[700],
),
],
),
),
],
),
), ),
); );
}).toList(), }).toList(),
@ -464,6 +475,11 @@ class _ServiceProjectDetailsScreenState
PillTabBar( PillTabBar(
controller: _tabController, controller: _tabController,
tabs: const ["Profile", "Jobs", "Teams"], tabs: const ["Profile", "Jobs", "Teams"],
icons: const [
Icons.person,
Icons.work,
Icons.group,
],
selectedColor: contentTheme.primary, selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600, unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary.withOpacity(0.1), indicatorColor: contentTheme.primary.withOpacity(0.1),
@ -475,7 +491,9 @@ class _ServiceProjectDetailsScreenState
child: Obx(() { child: Obx(() {
if (controller.isLoading.value && if (controller.isLoading.value &&
controller.projectDetail.value == null) { controller.projectDetail.value == null) {
return const Center(child: CircularProgressIndicator()); return Center(
child: SkeletonLoaders
.serviceProjectListSkeletonLoader());
} }
if (controller.errorMessage.value.isNotEmpty && if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) { controller.projectDetail.value == null) {

View File

@ -9,6 +9,7 @@ import 'package:on_field_work/model/service_project/service_projects_list_model.
import 'package:on_field_work/helpers/utils/date_time_utils.dart'; import 'package:on_field_work/helpers/utils/date_time_utils.dart';
import 'package:on_field_work/view/service_project/service_project_details_screen.dart'; import 'package:on_field_work/view/service_project/service_project_details_screen.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart'; import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class ServiceProjectScreen extends StatefulWidget { class ServiceProjectScreen extends StatefulWidget {
const ServiceProjectScreen({super.key}); const ServiceProjectScreen({super.key});
@ -200,7 +201,7 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
end: Alignment.bottomCenter, end: Alignment.bottomCenter,
colors: [ colors: [
appBarColor, appBarColor,
appBarColor.withOpacity(0.0), appBarColor.withOpacity(0.0),
], ],
), ),
), ),
@ -264,7 +265,9 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return Center(
child: SkeletonLoaders
.serviceProjectListSkeletonLoader());
} }
final projects = controller.filteredProjects; final projects = controller.filteredProjects;

View File

@ -29,6 +29,9 @@ class _SplashScreenState extends State<SplashScreen>
// Animation for logo and text fade-in // Animation for logo and text fade-in
late Animation<double> _opacityAnimation; late Animation<double> _opacityAnimation;
// Animation for the gradient shimmer effect (moves from -1.0 to 2.0)
late Animation<double> _shimmerAnimation;
@override @override
void initState() { void initState() {
super.initState(); super.initState();
@ -39,7 +42,7 @@ class _SplashScreenState extends State<SplashScreen>
vsync: this, vsync: this,
); );
// Initial scale-in: from 0.0 to 1.0 (happens in the first 40% of the duration) // Initial scale-in: from 0.5 to 1.0 (happens in the first 40% of the duration)
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate( _scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation( CurvedAnimation(
parent: _controller, parent: _controller,
@ -56,7 +59,15 @@ class _SplashScreenState extends State<SplashScreen>
), ),
); );
// Floating effect: from 0.0 to 1.0 (loops repeatedly after initial animations) // Shimmer/Gradient Animation: Moves the gradient horizontally from left to right
_shimmerAnimation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 1.0, curve: Curves.linear),
),
);
// Floating effect: from -8.0 to 8.0 (loops repeatedly after initial animations)
_floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate( _floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate(
CurvedAnimation( CurvedAnimation(
parent: _controller, parent: _controller,
@ -66,10 +77,10 @@ class _SplashScreenState extends State<SplashScreen>
// Start the complex animation sequence // Start the complex animation sequence
_controller.forward().then((_) { _controller.forward().then((_) {
// After the initial scale/fade, switch to repeating the float animation // After the initial scale/fade, switch to repeating the float and shimmer animation
if (mounted) { if (mounted) {
_controller.repeat( _controller.repeat(
min: 0.4, // Start repeat from the float interval min: 0.4, // Keep repeat range for float animation
max: 1.0, max: 1.0,
reverse: true, reverse: true,
); );
@ -83,6 +94,70 @@ class _SplashScreenState extends State<SplashScreen>
super.dispose(); super.dispose();
} }
// Widget for the multi-colored text with shimmering effect only on '.com'
Widget _buildAnimatedDomainText() {
const textStyle = TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
fontFamily: 'Roboto', // Use a clear, modern font
);
// The Shimmer Effect: AnimatedBuilder rebuilds the widget as the shimmerAnimation updates
return AnimatedBuilder(
animation: _shimmerAnimation,
builder: (context, child) {
// Define the silver gradient
final shimmerGradient = LinearGradient(
colors: const [
Colors.grey, // Starting dull color
Colors.white, // Brightest 'shimmer' highlight
Colors.grey, // Ending dull color
],
stops: const [0.3, 0.5, 0.7], // Position of colors
// The begin/end points move based on the animation value
begin:
Alignment(_shimmerAnimation.value - 1.0, 0.0), // Start from left
end: Alignment(_shimmerAnimation.value, 0.0), // End to right
);
// The Text Content: RichText allows for different styles within one text block
return RichText(
text: TextSpan(
style: textStyle.copyWith(color: Colors.black),
children: <TextSpan>[
// 'OnField' - Blue (#007bff)
const TextSpan(
text: 'OnField',
style: TextStyle(
color: Color(0xFF007BFF),
),
),
// 'Work' - Green (#71dd37)
const TextSpan(
text: 'Work',
style: TextStyle(
color: Color(0xFF71DD37),
),
),
// '.com' - Shimmer gradient
TextSpan(
text: '.com',
style: textStyle.copyWith(
foreground: Paint()
..shader = shimmerGradient.createShader(
const Rect.fromLTWH(0.0, 0.0, 150.0, 50.0),
),
),
),
],
),
);
},
);
}
// A simple, modern custom progress indicator // A simple, modern custom progress indicator
Widget _buildProgressIndicator() { Widget _buildProgressIndicator() {
return SizedBox( return SizedBox(
@ -98,7 +173,6 @@ class _SplashScreenState extends State<SplashScreen>
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: widget.backgroundColor, backgroundColor: widget.backgroundColor,
// Full screen display, no SafeArea needed for a full bleed splash
body: Center( body: Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@ -127,6 +201,15 @@ class _SplashScreenState extends State<SplashScreen>
const SizedBox(height: 30), const SizedBox(height: 30),
// **Corrected: Animated Domain Text with specific colors and only '.com' shimmering**
FadeTransition(
opacity: _opacityAnimation,
child: _buildAnimatedDomainText(),
),
const SizedBox(
height: 10), // Small space between new text and message
// Text Message (Fades in slightly after logo) // Text Message (Fades in slightly after logo)
if (widget.message != null) if (widget.message != null)
FadeTransition( FadeTransition(

View File

@ -84,20 +84,38 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
Padding( Padding(
padding: MySpacing.x(10), padding: MySpacing.x(10),
child: ServiceSelector( child: Obx(() {
controller: serviceController, // 1. Check if services are loading or empty
height: 40, if (serviceController.isLoadingServices.value) {
onSelectionChanged: (service) async { return ServiceSelector(
final projectId = widget.projectId; controller: serviceController,
if (projectId.isNotEmpty) { height: 40,
await dailyTaskPlanningController onSelectionChanged: (service) async {
.fetchTaskData( // Empty handler when loading
projectId, },
serviceId: service?.id, );
); }
}
}, if (serviceController.services.isEmpty) {
), return const _EmptyServiceWidget();
}
// 2. Display ServiceSelector if services are available
return ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
final projectId = widget.projectId;
if (projectId.isNotEmpty) {
await dailyTaskPlanningController
.fetchTaskData(
projectId,
serviceId: service?.id,
);
}
},
);
}),
), ),
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
Padding( Padding(
@ -126,12 +144,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly(); return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly();
} }
// Check 1: If no daily tasks are fetched at all
if (dailyTasks.isEmpty) { if (dailyTasks.isEmpty) {
return Center( return const _EmptyDataCard(
child: MyText.bodySmall( title: "No Daily Tasks Found",
"No Progress Report Found", subtitle: "No progress reports are planned for the selected filter.",
fontWeight: 600,
),
); );
} }
@ -164,11 +181,10 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
.toList(); .toList();
if (buildings.isEmpty) { if (buildings.isEmpty) {
return Center( return const _EmptyDataCard(
child: MyText.bodySmall( title: "No Progress Report Found",
"No Progress Report Found", subtitle:
fontWeight: 600, "No work is planned or completed for the selected service/project.",
),
); );
} }
@ -206,7 +222,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
projectId, projectId,
serviceController.selectedService?.id, serviceController.selectedService?.id,
); );
setMainState(() {}); setMainState(() {});
} }
} }
}, },
@ -247,11 +263,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
.dailyProgressPlanningInfraSkeleton(), .dailyProgressPlanningInfraSkeleton(),
) )
else if (!buildingLoaded || building.floors.isEmpty) else if (!buildingLoaded || building.floors.isEmpty)
Padding( const Padding(
padding: const EdgeInsets.all(16.0), padding: EdgeInsets.all(16.0),
child: MyText.bodySmall( child: _EmptyDataMessage(
"No Progress Report Found for this Project", message:
fontWeight: 600, "No floors or work data found for this building.",
), ),
) )
else else
@ -430,50 +446,58 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
.hasPermission(Permissions .hasPermission(Permissions
.assignReportTask)) .assignReportTask))
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons.person_add_alt_1_rounded, Icons
color: Color.fromARGB( .person_add_alt_1_rounded,
255, 46, 161, 233), color: Color.fromARGB(
), 255, 46, 161, 233),
onPressed: () { ),
final pendingTask = onPressed: () async {
(planned - completed) final pendingTask =
.clamp(0, planned) (planned - completed)
.toInt(); .clamp(0, planned)
.toInt();
showModalBottomSheet( // Wait until user closes bottom sheet
context: context, await showModalBottomSheet(
isScrollControlled: true, context: context,
shape: isScrollControlled: true,
const RoundedRectangleBorder( shape:
borderRadius: const RoundedRectangleBorder(
BorderRadius.vertical( borderRadius:
top: BorderRadius.vertical(
Radius.circular( top: Radius
16)), .circular(
), 16)),
builder: (context) => ),
AssignTaskBottomSheet( builder: (context) =>
buildingName: building.name, AssignTaskBottomSheet(
floorName: floor.floorName, buildingId: building.id,
workAreaName: area.areaName, buildingName:
workLocation: area.areaName, building.name,
activityName: item floorName:
.activityMaster floor.floorName,
?.name ?? workAreaName:
"Unknown Activity", area.areaName,
pendingTask: pendingTask, workLocation:
workItemId: area.areaName,
item.id.toString(), activityName: item
assignmentDate: .activityMaster
DateTime.now(), ?.name ??
), "Unknown Activity",
); pendingTask: pendingTask,
}, workItemId:
), item.id.toString(),
assignmentDate:
DateTime.now(),
),
);
}),
], ],
), ),
MySpacing.height(6), MySpacing.height(4),
Row( Row(
children: [ children: [
MyText.bodySmall( MyText.bodySmall(
@ -538,3 +562,80 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
}); });
} }
} }
// =====================================================================
// NEW EMPTY DATA WIDGETS
// =====================================================================
class _EmptyDataMessage extends StatelessWidget {
final String message;
const _EmptyDataMessage({required this.message});
@override
Widget build(BuildContext context) {
return Center(
child: MyText.bodySmall(
message,
fontWeight: 600,
color: Colors.grey.shade500,
textAlign: TextAlign.center,
),
);
}
}
class _EmptyServiceWidget extends StatelessWidget {
const _EmptyServiceWidget({Key? key}) : super(key: key);
@override
Widget build(BuildContext context) {
return Padding(
padding: const EdgeInsets.symmetric(vertical: 8.0),
child: Center(
child: MyText.bodyMedium(
'No services found for this project.',
fontWeight: 700,
color: Colors.grey.shade600,
),
),
);
}
}
class _EmptyDataCard extends StatelessWidget {
final String title;
final String subtitle;
const _EmptyDataCard({required this.title, required this.subtitle});
@override
Widget build(BuildContext context) {
return MyCard.bordered(
paddingAll: 16,
borderRadiusAll: 10,
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
Icon(
Icons.task_alt_outlined,
size: 48,
color: Colors.grey.shade400,
),
MySpacing.height(12),
MyText.titleMedium(
title,
fontWeight: 700,
color: Colors.grey.shade700,
textAlign: TextAlign.center,
),
MySpacing.height(4),
MyText.bodySmall(
subtitle,
color: Colors.grey.shade500,
textAlign: TextAlign.center,
),
],
),
);
}
}

View File

@ -50,13 +50,12 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
} }
Future<void> _onTenantSelected(String tenantId) async { Future<void> _onTenantSelected(String tenantId) async {
await _controller.onTenantSelected(tenantId); return _controller.onTenantSelected(tenantId);
} }
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Obx(() { return Obx(() {
// Splash screen for auto-selection
if (_controller.isAutoSelecting.value) { if (_controller.isAutoSelecting.value) {
return const SplashScreen(); return const SplashScreen();
} }
@ -91,6 +90,7 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
controller: _controller, controller: _controller,
isLoading: _controller.isLoading.value, isLoading: _controller.isLoading.value,
onTenantSelected: _onTenantSelected, onTenantSelected: _onTenantSelected,
primaryColor: contentTheme.primary,
), ),
], ],
), ),
@ -109,7 +109,6 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
} }
} }
/// Animated Logo Widget
class _AnimatedLogo extends StatelessWidget { class _AnimatedLogo extends StatelessWidget {
final Animation<double> animation; final Animation<double> animation;
const _AnimatedLogo({required this.animation}); const _AnimatedLogo({required this.animation});
@ -139,7 +138,6 @@ class _AnimatedLogo extends StatelessWidget {
} }
} }
/// Welcome Texts
class _WelcomeTexts extends StatelessWidget { class _WelcomeTexts extends StatelessWidget {
const _WelcomeTexts(); const _WelcomeTexts();
@ -166,7 +164,6 @@ class _WelcomeTexts extends StatelessWidget {
} }
} }
/// Beta Badge
class _BetaBadge extends StatelessWidget { class _BetaBadge extends StatelessWidget {
const _BetaBadge(); const _BetaBadge();
@ -188,16 +185,18 @@ class _BetaBadge extends StatelessWidget {
} }
} }
/// Tenant Card List class TenantCardList extends StatelessWidget {
class TenantCardList extends StatelessWidget with UIMixin {
final TenantSelectionController controller; final TenantSelectionController controller;
final bool isLoading; final bool isLoading;
final Function(String tenantId) onTenantSelected; final Function(String tenantId) onTenantSelected;
final Color primaryColor;
TenantCardList({ const TenantCardList({
super.key,
required this.controller, required this.controller,
required this.isLoading, required this.isLoading,
required this.onTenantSelected, required this.onTenantSelected,
required this.primaryColor,
}); });
@override @override
@ -226,18 +225,16 @@ class TenantCardList extends StatelessWidget with UIMixin {
(tenant) => _TenantCard( (tenant) => _TenantCard(
tenant: tenant, tenant: tenant,
onTap: () => onTenantSelected(tenant.id), onTap: () => onTenantSelected(tenant.id),
primaryColor: primaryColor,
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
TextButton.icon( TextButton.icon(
onPressed: () async { onPressed: LocalStorage.logout,
await LocalStorage.logout(); icon: Icon(Icons.arrow_back, size: 20, color: primaryColor),
},
icon:
Icon(Icons.arrow_back, size: 20, color: contentTheme.primary,),
label: MyText( label: MyText(
'Back to Login', 'Back to Login',
color: contentTheme.primary, color: primaryColor,
fontWeight: 600, fontWeight: 600,
fontSize: 14, fontSize: 14,
), ),
@ -248,11 +245,16 @@ class TenantCardList extends StatelessWidget with UIMixin {
} }
} }
/// Single Tenant Card class _TenantCard extends StatelessWidget {
class _TenantCard extends StatelessWidget with UIMixin {
final dynamic tenant; final dynamic tenant;
final VoidCallback onTap; final VoidCallback onTap;
_TenantCard({required this.tenant, required this.onTap}); final Color primaryColor;
const _TenantCard({
required this.tenant,
required this.onTap,
required this.primaryColor,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -297,7 +299,7 @@ class _TenantCard extends StatelessWidget with UIMixin {
], ],
), ),
), ),
Icon(Icons.arrow_forward_ios, size: 24, color: contentTheme.primary,), Icon(Icons.arrow_forward_ios, size: 24, color: primaryColor),
], ],
), ),
), ),
@ -306,7 +308,6 @@ class _TenantCard extends StatelessWidget with UIMixin {
} }
} }
/// Tenant Logo (supports base64 and URL)
class TenantLogo extends StatelessWidget { class TenantLogo extends StatelessWidget {
final String? logoImage; final String? logoImage;
const TenantLogo({required this.logoImage}); const TenantLogo({required this.logoImage});
@ -324,14 +325,13 @@ class TenantLogo extends StatelessWidget {
} catch (_) { } catch (_) {
return Center(child: Icon(Icons.business, color: Colors.grey.shade600)); return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
} }
} else {
return Image.network(
logoImage!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) => Center(
child: Icon(Icons.business, color: Colors.grey.shade600),
),
);
} }
return Image.network(
logoImage!,
fit: BoxFit.cover,
errorBuilder: (_, __, ___) =>
Center(child: Icon(Icons.business, color: Colors.grey.shade600)),
);
} }
} }

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 # 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 # 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. # of the product and file versions while build-number is used as the build suffix.
version: 1.0.0+18 version: 1.0.1+20
environment: environment:
sdk: ^3.5.3 sdk: ^3.5.3
@ -46,17 +46,17 @@ dependencies:
carousel_slider: ^5.0.0 carousel_slider: ^5.0.0
reorderable_grid: ^1.0.10 reorderable_grid: ^1.0.10
loading_animation_widget: ^1.3.0 loading_animation_widget: ^1.3.0
intl: ^0.19.0 intl: ^0.20.2
syncfusion_flutter_core: ^29.1.40 syncfusion_flutter_core: ^31.2.18
syncfusion_flutter_sliders: ^29.1.40 syncfusion_flutter_sliders: ^31.2.18
file_picker: ^10.3.2 file_picker: ^10.3.2
timelines_plus: ^1.0.4 timelines_plus: ^1.0.4
syncfusion_flutter_charts: ^29.1.40 syncfusion_flutter_charts: ^31.2.18
appflowy_board: ^0.1.2 appflowy_board: ^0.1.2
syncfusion_flutter_calendar: ^29.1.40 syncfusion_flutter_calendar: ^31.2.18
syncfusion_flutter_maps: ^29.1.40 syncfusion_flutter_maps: ^31.2.18
http: ^1.6.0 http: ^1.6.0
geolocator: ^14.0.2 geolocator: ^14.0.1
permission_handler: ^12.0.1 permission_handler: ^12.0.1
image: ^4.0.17 image: ^4.0.17
image_picker: ^1.0.7 image_picker: ^1.0.7
@ -71,13 +71,13 @@ dependencies:
font_awesome_flutter: ^10.8.0 font_awesome_flutter: ^10.8.0
flutter_html: ^3.0.0 flutter_html: ^3.0.0
tab_indicator_styler: ^2.0.0 tab_indicator_styler: ^2.0.0
connectivity_plus: ^6.1.4 connectivity_plus: ^7.0.0
geocoding: ^4.0.0 geocoding: ^4.0.0
firebase_core: ^4.0.0 firebase_core: ^4.0.0
firebase_messaging: ^16.0.0 firebase_messaging: ^16.0.0
googleapis_auth: ^2.0.0 googleapis_auth: ^2.0.0
device_info_plus: ^11.3.0 device_info_plus: ^12.3.0
flutter_local_notifications: 19.4.0 flutter_local_notifications: ^19.5.0
equatable: ^2.0.7 equatable: ^2.0.7
mime: ^2.0.0 mime: ^2.0.0
timeago: ^3.7.1 timeago: ^3.7.1
@ -86,7 +86,9 @@ dependencies:
gallery_saver_plus: ^3.2.9 gallery_saver_plus: ^3.2.9
share_plus: ^12.0.1 share_plus: ^12.0.1
timeline_tile: ^2.0.0 timeline_tile: ^2.0.0
encrypt: ^5.0.3
flutter_in_store_app_version_checker: ^1.10.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
sdk: flutter sdk: flutter
@ -96,7 +98,7 @@ dev_dependencies:
# activated in the `analysis_options.yaml` file located at the root of your # activated in the `analysis_options.yaml` file located at the root of your
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^5.0.0 flutter_lints: ^6.0.0
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec
@ -149,6 +151,3 @@ flutter:
# #
# For details regarding fonts from package dependencies, # For details regarding fonts from package dependencies,
# see https://flutter.dev/to/font-from-package # see https://flutter.dev/to/font-from-package
dependency_overrides:
http: ^1.6.0