Compare commits

..

No commits in common. "Manish_Dev_13/12" and "main" have entirely different histories.

74 changed files with 5287 additions and 6167 deletions

View File

@ -1,4 +1,4 @@
# OnFieldWork.com # On Field Work
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
minSdkVersion = flutter.minSdkVersion minSdk = 23
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="OnFieldWork.com" android:label="On Field Work"
android:name="${applicationName}" android:name="${applicationName}"
android:icon="@mipmap/ic_launcher"> android:icon="@mipmap/ic_launcher">
<activity <activity

View File

@ -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.13-all.zip distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-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.9.1" apply false id "com.android.application" version "8.6.0" 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="OnFieldWork.com" APP_NAME="On Field Work"
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>OnFieldWork.com</string> <string>On Field Work</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 => 'OnFieldWork.com'; static String get appName => 'On Field Work';
} }

View File

@ -12,21 +12,26 @@ 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());
// -------------------------- // =========================
// STATE VARIABLES // 1. 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;
@ -39,12 +44,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;
@ -58,17 +63,21 @@ 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 = const ['7D', '15D', '30D']; final List<String> ranges = ['7D', '15D', '30D'];
static const _rangeDaysMap = { static const _rangeDaysMap = {
'7D': 7, '7D': 7,
'15D': 15, '15D': 15,
@ -77,22 +86,19 @@ 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;
@ -105,46 +111,44 @@ class DashboardController extends GetxController {
return weightedDue / data.totalDueAmount; return weightedDue / data.totalDueAmount;
} }
// -------------------------- // =========================
// LIFECYCLE // 3. 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) {
_latestProjectId = id; // track latest project fetchAllDashboardData();
fetchAllDashboardData(id); fetchTodaysAttendance(id);
} }
}); });
// Expense Report Date Listener // Expense Report Date Listener
everAll([expenseReportStartDate, expenseReportEndDate], (_) { everAll([expenseReportStartDate, expenseReportEndDate], (_) {
final id = projectController.selectedProjectId.value; if (projectController.selectedProjectId.value.isNotEmpty) {
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());
} }
// -------------------------- // =========================
// USER ACTIONS // 4. 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;
@ -159,6 +163,7 @@ 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,
@ -171,8 +176,7 @@ class DashboardController extends GetxController {
fetchMonthlyExpenses(); fetchMonthlyExpenses();
} }
Future<void> refreshDashboard() => Future<void> refreshDashboard() => fetchAllDashboardData();
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 {
@ -180,78 +184,150 @@ class DashboardController extends GetxController {
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id); if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
} }
// -------------------------- // =========================
// HELPER: Execute API call // 5. DATA FETCHING (API)
// -------------------------- // =========================
/// Wrapper to reduce try-finally boilerplate for loading states
Future<void> _executeApiCall( Future<void> _executeApiCall(
RxBool loaderRx, Future<void> Function() apiLogic) async { RxBool loader, Future<void> Function() apiLogic) async {
loaderRx.value = true; loader.value = true;
try { try {
await apiLogic(); await apiLogic();
} catch (e, stack) {
logSafe('API Call Failed: $e', level: LogLevel.error, stackTrace: stack);
} finally { } finally {
loaderRx.value = false; loader.value = false;
} }
} }
// -------------------------- Future<void> fetchAllDashboardData() async {
// API FETCHES final String projectId = projectController.selectedProjectId.value;
// --------------------------
Future<void> fetchAllDashboardData(String projectId) async {
if (projectId.isEmpty) return; if (projectId.isEmpty) return;
_latestProjectId = projectId;
await Future.wait([ await Future.wait([
fetchRoleWiseAttendance(projectId), fetchRoleWiseAttendance(),
fetchProjectProgress(projectId), fetchProjectProgress(),
fetchDashboardTasks(projectId: projectId), fetchDashboardTasks(projectId: projectId),
fetchDashboardTeams(projectId: projectId), fetchDashboardTeams(projectId: projectId),
fetchPendingExpenses(projectId), fetchPendingExpenses(),
fetchExpenseTypeReport( fetchExpenseTypeReport(
startDate: expenseReportStartDate.value, startDate: expenseReportStartDate.value,
endDate: expenseReportEndDate.value, endDate: expenseReportEndDate.value,
projectId: projectId,
), ),
fetchMonthlyExpenses(projectId: projectId), fetchMonthlyExpenses(),
fetchMasterData(), fetchMasterData(),
fetchCollectionOverview(projectId), fetchCollectionOverview(),
fetchPurchaseInvoiceOverview(projectId), fetchPurchaseInvoiceOverview(),
fetchTodaysAttendance(projectId),
]); ]);
} }
// -------------------------- Future<void> fetchCollectionOverview() async {
// Each fetch now ignores stale project responses final projectId = projectController.selectedProjectId.value;
// -------------------------- if (projectId.isEmpty) return;
Future<void> fetchRoleWiseAttendance([String? projectId]) async { await _executeApiCall(isCollectionOverviewLoading, () async {
final id = projectId ?? projectController.selectedProjectId.value; final response =
if (id.isEmpty) return; await ApiService.getCollectionOverview(projectId: projectId);
collectionOverviewData.value =
final localId = id; (response?.success == true) ? response!.data : null;
await _executeApiCall(isAttendanceLoading, () async {
final response = await ApiService.getDashboardAttendanceOverview(
id, getAttendanceDays());
if (_latestProjectId != localId) return; // discard stale response
roleWiseData.assignAll(
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? []);
}); });
} }
Future<void> fetchProjectProgress([String? projectId]) async { Future<void> fetchTodaysAttendance(String projectId) async {
final id = projectId ?? projectController.selectedProjectId.value; 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;
await _executeApiCall(isAttendanceLoading, () async {
final response = await ApiService.getDashboardAttendanceOverview(
id, getAttendanceDays());
roleWiseData.value =
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
});
}
Future<void> fetchExpenseTypeReport(
{required DateTime startDate, required DateTime endDate}) async {
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.assignAll(response!.data projectChartData.value = response!.data
.map((d) => ChartTaskData.fromProjectData(d)) .map((d) => ChartTaskData.fromProjectData(d))
.toList()); .toList();
} else { } else {
projectChartData.clear(); projectChartData.clear();
} }
@ -259,115 +335,27 @@ 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 (_latestProjectId != localId) return; if (response?.success == true) {
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 (_latestProjectId != localId) return; if (response?.success == true) {
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,18 +7,20 @@ 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 = true.obs; RxBool isLoading = false.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;
@ -29,51 +31,26 @@ class EmployeesScreenController extends GetxController {
fetchAllEmployees(); fetchAllEmployees();
} }
/// 🔹 Search/Filter Logic /// 🔹 Fetch all employees (no project filter)
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) {
final loadedList = employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
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",
@ -113,14 +90,16 @@ class EmployeesScreenController extends GetxController {
isLoadingEmployeeDetails.value = false; isLoadingEmployeeDetails.value = false;
} }
/// Fetch reporting managers /// Fetch reporting managers for a specific employee from /organization/hierarchy/list/:employeeId
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) {
@ -145,8 +124,11 @@ 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",
@ -157,13 +139,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,
@ -186,6 +168,7 @@ 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,26 +44,21 @@ 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;
} }
@ -71,6 +66,7 @@ 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>();
@ -97,7 +93,6 @@ class AddExpenseController extends GetxController {
employeeSearchController.addListener( employeeSearchController.addListener(
() => searchEmployees(employeeSearchController.text), () => searchEmployees(employeeSearchController.text),
); );
ever(selectedPaymentMode, (_) => _checkTransactionIdExemption());
} }
@override @override
@ -108,12 +103,6 @@ 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();
@ -182,7 +171,6 @@ 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 {
@ -548,11 +536,6 @@ 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,9 +4,6 @@ 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);
@ -20,22 +17,6 @@ class ExpenseDetailController extends GetxController {
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) {
if (_isInitialized) return; if (_isInitialized) return;
@ -50,36 +31,6 @@ 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 {
@ -112,8 +63,6 @@ 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",
@ -126,6 +75,8 @@ 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) {
@ -180,6 +131,8 @@ 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

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) { if (response != null && response.data != 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,7 +1,6 @@
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;
@ -10,39 +9,25 @@ 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 = final response = await ApiService.getInfraProjectDetails(projectId: projectId);
await ApiService.getInfraProjectDetails(projectId: projectId);
if (response != null && if (response != null && response.success == true && response.data != null) {
response.success == true &&
response.data != null) {
projectDetails.value = response.data; projectDetails.value = response.data;
errorMessage.value = ''; isLoading.value = false;
} else { } else {
errorMessage.value = errorMessage.value = response?.message ?? "Failed to load project details";
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";
@ -50,28 +35,4 @@ 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/auth_service.dart'; import 'package:on_field_work/helpers/services/permission_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 AuthService.fetchAllUserData(token); final userData = await PermissionService.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,19 +7,14 @@ 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 isProjectSelectionExpanded = false.obs;
RxBool isProjectListExpanded = false.obs; RxBool isProjectListExpanded = false.obs;
RxBool isProjectDropdownExpanded = false.obs; RxBool isProjectSelectionExpanded = 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);
@ -31,37 +26,35 @@ 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;
isLoading.value = false;
isLoadingProjects.value = false; isLoadingProjects.value = false;
isLoading.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 {
isLoading.value = true;
isLoadingProjects.value = true; isLoadingProjects.value = true;
isLoading.value = true;
try {
final response = await ApiService.getGlobalProjects(); final response = await ApiService.getGlobalProjects();
if (response != null && response.isNotEmpty) { if (response != null && response.isNotEmpty) {
projects.assignAll(response.map((json) => GlobalProjectModel.fromJson(json)).toList()); projects.assignAll(
response.map((json) => GlobalProjectModel.fromJson(json)).toList(),
);
// Load previously saved project
String? savedId = LocalStorage.getString('selectedProjectId'); String? savedId = LocalStorage.getString('selectedProjectId');
if (savedId != null && projects.any((p) => p.id == savedId)) { if (savedId != null && projects.any((p) => p.id == savedId)) {
selectedProjectId.value = savedId; selectedProjectId.value = savedId;
@ -70,24 +63,21 @@ class ProjectController extends GetxController {
LocalStorage.saveString('selectedProjectId', selectedProjectId.value); LocalStorage.saveString('selectedProjectId', selectedProjectId.value);
} }
isProjectSelectionExpanded.value = false;
logSafe("Projects fetched: ${projects.length}"); logSafe("Projects fetched: ${projects.length}");
} else { } else {
logSafe("No projects found or API call failed.", level: LogLevel.warning); logSafe("No Global projects found or API call failed.", level: LogLevel.warning);
} }
} catch (e, stack) {
logSafe("Error fetching projects: $e", level: LogLevel.error, stackTrace: stack);
} finally {
isLoading.value = false;
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");
isProjectSelectionExpanded.value = false; update(['selected_project']);
update();
} }
} }

View File

@ -12,8 +12,7 @@ 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 = [];
RxList<TaskPlanningDetailsModel> dailyTasks = List<TaskPlanningDetailsModel> dailyTasks = [];
<TaskPlanningDetailsModel>[].obs;
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs; RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
MyFormValidator basicValidator = MyFormValidator(); MyFormValidator basicValidator = MyFormValidator();
@ -28,7 +27,6 @@ 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();
@ -74,8 +72,6 @@ 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,
@ -97,8 +93,6 @@ 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!",
@ -129,17 +123,18 @@ 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.clear(); //reactive clear dailyTasks = [];
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();
final mapped = filteredBuildings.map((buildingJson) { dailyTasks = filteredBuildings.map((buildingJson) {
final building = Building( final building = Building(
id: buildingJson['id'], id: buildingJson['id'],
name: buildingJson['buildingName'], name: buildingJson['buildingName'],
@ -162,31 +157,30 @@ class DailyTaskPlanningController extends GetxController {
); );
}).toList(); }).toList();
dailyTasks.assignAll(mapped);
buildingLoadingStates.clear(); buildingLoadingStates.clear();
buildingsWithDetails.clear(); buildingsWithDetails.clear();
} catch (e, stack) { } catch (e, stack) {
logSafe( logSafe("Error fetching daily task data",
"Error fetching daily task data", level: LogLevel.error, error: e, stackTrace: stack);
level: LogLevel.error,
error: e,
stackTrace: stack,
);
} finally { } finally {
isFetchingTasks.value = false; isFetchingTasks.value = false;
update();
} }
} }
/// Fetch full infra for a single building (lazy) /// Fetch full infra for a single building (floors, workAreas, workItems).
/// 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; // Rx change is enough buildingLoadingStates[buildingId]!.value = true;
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>? ?? [];
@ -202,6 +196,7 @@ 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'],
@ -216,7 +211,7 @@ class DailyTaskPlanningController extends GetxController {
return WorkArea( return WorkArea(
id: areaJson['id'], id: areaJson['id'],
areaName: areaJson['areaName'], areaName: areaJson['areaName'],
workItems: [], workItems: [], // will populate later
); );
}).toList(), }).toList(),
); );
@ -225,6 +220,7 @@ 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 {
@ -259,6 +255,7 @@ 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
@ -270,6 +267,7 @@ 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,
@ -282,6 +280,7 @@ 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",
@ -289,7 +288,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(); // dailyTasks mutated update();
} }
} }
@ -362,7 +361,7 @@ class DailyTaskPlanningController extends GetxController {
} }
} finally { } finally {
isFetchingEmployees.value = false; isFetchingEmployees.value = false;
// no update(): RxLists/RxBools notify observers update();
} }
} }
} }

View File

@ -1,12 +1,14 @@
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/auth_service.dart'; import 'package:on_field_work/helpers/services/tenant_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;
@ -30,11 +32,10 @@ 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 AuthService.getTenants(); final data = await _tenantService.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;
} }
@ -86,7 +87,7 @@ class TenantSelectionController extends GetxController {
try { try {
isLoading.value = true; isLoading.value = true;
final success = await AuthService.selectTenant(tenantId); final success = await _tenantService.selectTenant(tenantId);
if (!success) { if (!success) {
showAppSnackbar( showAppSnackbar(
title: "Error", title: "Error",
@ -98,7 +99,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);
AuthService.setSelectedTenant(selectedTenant); TenantService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId; selectedTenantId.value = tenantId;
await LocalStorage.setRecentTenantId(tenantId); await LocalStorage.setRecentTenantId(tenantId);
@ -130,6 +131,6 @@ class TenantSelectionController extends GetxController {
/// Clear tenant selection /// Clear tenant selection
void _clearSelection() { void _clearSelection() {
selectedTenantId.value = null; selectedTenantId.value = null;
AuthService.currentTenant = null; TenantService.currentTenant = null;
} }
} }

View File

@ -1,12 +1,13 @@
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/auth_service.dart'; import 'package:on_field_work/helpers/services/tenant_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;
@ -22,7 +23,7 @@ class TenantSwitchController extends GetxController {
Future<void> loadTenants() async { Future<void> loadTenants() async {
isLoading.value = true; isLoading.value = true;
try { try {
final data = await AuthService.getTenants(); final data = await _tenantService.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);
@ -32,7 +33,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 = AuthService.currentTenant?.id; selectedTenantId.value = TenantService.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(
@ -47,11 +48,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 (AuthService.currentTenant?.id == tenantId) return; if (TenantService.currentTenant?.id == tenantId) return;
isLoading.value = true; isLoading.value = true;
try { try {
final success = await AuthService.selectTenant(tenantId); final success = await _tenantService.selectTenant(tenantId);
if (!success) { if (!success) {
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning); logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
showAppSnackbar( showAppSnackbar(
@ -63,7 +64,7 @@ class TenantSwitchController extends GetxController {
} }
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId); final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
AuthService.setSelectedTenant(selectedTenant); TenantService.setSelectedTenant(selectedTenant);
selectedTenantId.value = tenantId; selectedTenantId.value = tenantId;
// Persist recent tenant // Persist recent tenant

View File

@ -3,7 +3,8 @@ 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 =
@ -47,8 +48,7 @@ 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 = static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
"/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,6 +142,7 @@ 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";
@ -150,14 +151,10 @@ 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 = static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance";
"/serviceproject/job/attendance"; static const String serviceProjectUpateJobAttendanceLog = "/serviceproject/job/attendance/log";
static const String serviceProjectUpateJobAttendanceLog = static const String getServiceProjectUpateJobAllocationList = "/serviceproject/get/allocation/list";
"/serviceproject/job/attendance/log"; static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation";
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";
@ -170,6 +167,4 @@ 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,16 +38,11 @@ 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.");
} }
@ -56,67 +51,43 @@ 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."); logSafe("💡 UI setup completed with default system behavior.");
} }
/// ---------------------------------------------------------------------------
/// 🔹 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,47 +1,27 @@
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> _defaultHeaders = { static const Map<String, String> _headers = {
'Content-Type': 'application/json', 'Content-Type': 'application/json',
}; };
// AuthService properties
static bool isLoggedIn = false; static bool isLoggedIn = false;
/* -------------------------------------------------------------------------- */
// TenantService properties /* Logout API */
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 = {"refreshToken": refreshToken, "fcmToken": fcmToken}; final body = {
final response = await _networkRequest( "refreshToken": refreshToken,
path: "/auth/logout", "fcmToken": fcmToken,
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");
@ -57,7 +37,10 @@ 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) {
@ -67,36 +50,38 @@ class AuthService {
} }
final body = {"fcmToken": fcmToken}; final body = {"fcmToken": fcmToken};
final response = await _networkRequest( final headers = {
path: "/auth/set/device-token", ..._headers,
method: _HttpMethod.post, 'Authorization': 'Bearer $token',
body: body, };
authToken: token, final endpoint = "$_baseUrl/auth/set/device-token";
);
if (response != null && response['success'] == true) { // 🔹 Log request details
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: ${response?['message']}", logSafe("⚠️ Failed to register device token: ${data?['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...");
final responseData = await _networkRequest( logSafe("Login payload (raw): $data");
path: "/auth/app/login", logSafe("Login payload (JSON): ${jsonEncode(data)}");
method: _HttpMethod.post,
body: data,
);
if (responseData == null) { final responseData = await _post("/auth/app/login", data);
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']);
@ -108,10 +93,9 @@ 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 = await LocalStorage.getJwtToken(); final accessToken = LocalStorage.getJwtToken();
final refreshToken = await LocalStorage.getRefreshToken(); final refreshToken = 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);
@ -119,22 +103,24 @@ class AuthService {
} }
final body = {"token": accessToken, "refreshToken": refreshToken}; final body = {"token": accessToken, "refreshToken": refreshToken};
final data = await _networkRequest( final data = await _post("/auth/refresh-token", body);
path: "/auth/refresh-token", if (data != null && data['success'] == true) {
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.");
final newFcmToken = await LocalStorage.getFcmToken(); // 🔹 Retry FCM token registration after token refresh
final newFcmToken = LocalStorage.getFcmToken();
if (newFcmToken?.isNotEmpty ?? false) { if (newFcmToken?.isNotEmpty ?? false) {
await registerDeviceToken(newFcmToken!); final success = 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']}",
@ -142,29 +128,35 @@ 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( _wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}),
() => _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.");
/// Generates an MPIN for the user. static Future<Map<String, String>?> requestDemo(
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 = await LocalStorage.getJwtToken(); final token = LocalStorage.getJwtToken();
return _networkRequest( return _post(
path: "/auth/generate-mpin", "/auth/generate-mpin",
method: _HttpMethod.post, {"employeeId": employeeId, "mpin": mpin},
body: {"employeeId": employeeId, "mpin": mpin},
authToken: token, authToken: token,
); );
}, },
@ -172,7 +164,6 @@ 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,
@ -180,15 +171,12 @@ class AuthService {
}) => }) =>
_wrapErrorHandling( _wrapErrorHandling(
() async { () async {
final employeeInfo = await LocalStorage.getEmployeeInfo(); final employeeInfo = LocalStorage.getEmployeeInfo();
if (employeeInfo == null) if (employeeInfo == null) return null;
return null; // Fails immediately if info is missing
final token = await LocalStorage.getJwtToken(); final token = await LocalStorage.getJwtToken();
return _post(
final responseData = await _networkRequest( "/auth/login-mpin",
path: "/auth/login-mpin", {
method: _HttpMethod.post,
body: {
"employeeId": employeeInfo.id, "employeeId": employeeInfo.id,
"mpin": mpin, "mpin": mpin,
"mpinToken": mpinToken, "mpinToken": mpinToken,
@ -196,41 +184,21 @@ 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( _wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}),
() => _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 _networkRequest( final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
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;
@ -238,309 +206,55 @@ class AuthService {
return {"error": data?['message'] ?? "OTP verification failed."}; return {"error": data?['message'] ?? "OTP verification failed."};
} }
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/* MARKET/OTHER METHODS                          */ /* Private Utilities */
/* -------------------------------------------------------------------------- */ /* -------------------------------------------------------------------------- */
/// Submits a demo request to the market endpoint. static Future<Map<String, dynamic>?> _post(
static Future<Map<String, String>?> requestDemo( String path,
Map<String, dynamic> demoData) => Map<String, dynamic> body, {
_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 {
logSafe( final headers = {
"➡️ ${method.name.toUpperCase()} $_baseUrl$path${body != null ? '\nBody: ${jsonEncode(body)}' : ''}", ..._headers,
level: LogLevel.info); if (authToken?.isNotEmpty ?? false)
'Authorization': 'Bearer $authToken',
if (method == _HttpMethod.post) {
response =
await http.post(uri, headers: headers, body: jsonEncode(body));
} else {
// GET
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 response = await http.post(Uri.parse("$_baseUrl$path"),
headers: headers, body: jsonEncode(body));
final decrypted = decryptResponse(response.body);
if (decrypted == null) {
logSafe("❌ Response decryption failed for $path",
level: LogLevel.error);
return { return {
...jsonDecode(response.body),
"statusCode": response.statusCode, "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 ${method.name.toUpperCase()} error", e, st); _handleError("$path POST 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,
@ -551,13 +265,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);
@ -573,5 +287,6 @@ class AuthService {
await LocalStorage.removeMpinToken(); await LocalStorage.removeMpinToken();
} }
isLoggedIn = true; isLoggedIn = true;
logSafe("✅ Login flow completed and controllers initialized.");
} }
} }

View File

@ -1,255 +0,0 @@
// 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

@ -0,0 +1,111 @@
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,6 +2,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/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';
@ -138,7 +139,6 @@ 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);
@ -153,9 +153,10 @@ class LocalStorage {
await preferences.remove(_themeCustomizerKey); await preferences.remove(_themeCustomizerKey);
await preferences.remove('selectedProjectId'); await preferences.remove('selectedProjectId');
// Clear all GetX controllers if (Get.isRegistered<ProjectController>()) {
Get.deleteAll(force: true); Get.find<ProjectController>().clearProjects();
// Navigate to login }
Get.offAllNamed('/auth/login-option'); Get.offAllNamed('/auth/login-option');
} }

View File

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

@ -1,75 +0,0 @@
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,9 +5,11 @@ 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 implements PreferredSizeWidget { class CustomAppBar extends StatefulWidget
with UIMixin
implements PreferredSizeWidget {
final String title; final String title;
final String? projectName; final String? projectName; // If passed, show static text
final VoidCallback? onBackPressed; final VoidCallback? onBackPressed;
final Color? backgroundColor; final Color? backgroundColor;

View File

@ -28,7 +28,9 @@ 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: const _EmptyDataWidget(), // <-- Use the new empty widget here child: Center(
child: MyText.bodyMedium('No collection overview data available.'),
),
); );
} }
@ -285,71 +287,6 @@ 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,39 +12,19 @@ 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 Container( return SkeletonLoaders.purchaseInvoiceDashboardSkeleton();
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 Container( return Center(
decoration: cardDecoration, // Apply decoration to empty state child: MyText.bodySmall('No purchase invoices found.'),
padding: const EdgeInsets.all(16.0),
child: const _EmptyDataWidget(), // <-- Use the new empty widget
); );
} }
@ -62,17 +42,27 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices); final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
return _buildDashboard(metrics, cardDecoration); return _buildDashboard(metrics);
}); });
} }
Widget _buildDashboard(PurchaseInvoiceMetrics metrics, BoxDecoration decoration) { Widget _buildDashboard(PurchaseInvoiceMetrics metrics) {
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: decoration, // Use the passed decoration decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch, crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min, mainAxisSize: MainAxisSize.min,
@ -329,56 +319,6 @@ 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;

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(5), borderRadius: BorderRadius.circular(6),
), ),
); );
}), }),
@ -35,149 +35,19 @@ 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 color setup (using grey for shimmer) gradient: LinearGradient(
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(
@ -186,67 +56,78 @@ static Widget _buildDetailRowSkeleton({
// Row with avatar and texts // Row with avatar and texts
Row( Row(
children: [ children: [
// Avatar (Size 30) // Avatar
Container( Container(
width: 30, width: 30,
height: 30, height: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, shape: BoxShape.circle)), color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
MySpacing.width(10), MySpacing.width(10),
// Name + designation (Approximate heights for MyText.titleSmall and MyText.labelSmall) // Name + designation
Expanded( Expanded(
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
height: 12, width: 100, color: Colors.grey.shade400), height: 12,
MySpacing.height( width: 100,
4), // Reduced from 6, guessing labelSmall is shorter color: Colors.grey.shade400,
Container(
height: 10, width: 70, color: Colors.grey.shade400),
],
), ),
),
// Status (MyText.bodySmall, height approx 12-14)
Container(
height: 14,
width: 80,
color: Colors
.grey.shade400), // Adjusted width and height slightly
],
),
const SizedBox(height: 12),
// Description (2 lines of Text, font size 13)
Container(
height: 14,
width: double.infinity,
color: Colors.grey
.shade400), // Height for one line of text size 13 + padding
MySpacing.height(6), MySpacing.height(6),
Container( Container(
height: 14, height: 10,
width: double.infinity * 0.7, width: 70,
color: Colors.grey.shade400), // Shorter second line color: Colors.grey.shade400,
),
],
),
),
// Status
Container(
height: 12,
width: 60,
color: Colors.grey.shade400,
),
],
),
const SizedBox(height: 12), const SizedBox(height: 12),
// Action buttons (Row at the end) // Description
Container(
height: 10,
width: double.infinity,
color: Colors.grey.shade400,
),
MySpacing.height(6),
Container(
height: 10,
width: double.infinity,
color: Colors.grey.shade400,
),
const SizedBox(height: 12),
// Action buttons
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
// Check In/Out Button (Approx height 28)
Container( Container(
height: 32, height: 28,
width: 100, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, color: Colors.grey.shade400,
borderRadius: borderRadius: BorderRadius.circular(4),
BorderRadius.circular(5))), // Larger button size ),
),
MySpacing.width(8), MySpacing.width(8),
// Log View Button (Icon Button, approx size 28-32)
Container( Container(
height: 32, height: 28,
width: 32, width: 28,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade400, shape: BoxShape.circle)), color: Colors.grey.shade400,
shape: BoxShape.circle,
),
),
], ],
), ),
], ],
@ -258,49 +139,16 @@ static Widget _buildDetailRowSkeleton({
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;
double crossAxisSpacing = 15; int crossAxisCount = (width ~/ 80).clamp(2, 4);
int crossAxisCount = 3; double cardWidth = (width - (crossAxisCount - 1) * 6) / crossAxisCount;
// Calculation remains the same: screen_width - (spacing * (count - 1)) / count return Wrap(
double totalHorizontalSpace = spacing: 6,
width - (crossAxisSpacing * (crossAxisCount - 1)); runSpacing: 6,
double cardWidth = totalHorizontalSpace / crossAxisCount; children: List.generate(6, (index) {
// Dynamic height calculation: width / 1.8 (e.g., 92.0 / 1.8 = 51.11, not 46.7)
// Rerunning the calculation based on the constraint h=46.7 given in the error:
// If cardWidth = 92.0, the aspect ratio must be different, or the parent widget
// is forcing a smaller height. To fix the overflow, we must assume the target
// height is fixed by the aspect ratio and reduce the inner content size.
double cardHeight = cardWidth / 1.8;
// Inner available vertical space (cardHeight - 2 * paddingAll):
// If cardHeight is 51.11, inner space is 51.11 - 8 = 43.11.
// If cardHeight is 46.7 (as per error constraint), inner space is 46.7 - 8 = 38.7.
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// Skeleton for the "Modules" title (fontSize 16, fontWeight 700)
Container(
margin: const EdgeInsets.only(left: 4, bottom: 8),
height: 18,
width: 80,
color: Colors.grey.shade300),
GridView.builder(
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( return MyCard.bordered(
width: cardWidth, width: cardWidth,
height: cardHeight, height: 60,
paddingAll: 4, paddingAll: 4,
borderRadiusAll: 5, borderRadiusAll: 5,
border: Border.all(color: Colors.grey.withOpacity(0.15)), border: Border.all(color: Colors.grey.withOpacity(0.15)),
@ -308,98 +156,29 @@ static Widget _buildDetailRowSkeleton({
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
// Icon placeholder: Reduced size to 16
Container( Container(
width: 16, width: 16,
height: 16, // Reduced from 20 height: 16,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
MySpacing.height(4), // Reduced spacing from 6 MySpacing.height(4),
// Text placeholder 1: Reduced height to 8 Container(
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, width: cardWidth * 0.5,
height: 8, // Reduced from 10 height: 10,
color: Colors.grey.shade300, 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),
@ -419,7 +198,7 @@ static Widget _buildDetailRowSkeleton({
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@ -432,7 +211,7 @@ static Widget _buildDetailRowSkeleton({
width: 50, width: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade200, color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
const SizedBox(width: 8), const SizedBox(width: 8),
@ -441,7 +220,7 @@ static Widget _buildDetailRowSkeleton({
height: 12, height: 12,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
), ),
@ -460,7 +239,7 @@ static Widget _buildDetailRowSkeleton({
width: 50, width: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade200, color: Colors.grey.shade200,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@ -469,7 +248,7 @@ static Widget _buildDetailRowSkeleton({
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
], ],
@ -481,7 +260,7 @@ static Widget _buildDetailRowSkeleton({
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
], ],
@ -502,7 +281,7 @@ static Widget _buildDetailRowSkeleton({
constraints: const BoxConstraints(maxWidth: 520), constraints: const BoxConstraints(maxWidth: 520),
child: MyCard.bordered( child: MyCard.bordered(
paddingAll: 16, paddingAll: 16,
borderRadiusAll: 5, borderRadiusAll: 8,
shadow: MyShadow(elevation: 3), shadow: MyShadow(elevation: 3),
child: ShimmerEffect( child: ShimmerEffect(
child: Column( child: Column(
@ -566,7 +345,7 @@ static Widget _buildDetailRowSkeleton({
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(8),
), ),
), ),
)), )),
@ -635,7 +414,7 @@ static Widget _buildDetailRowSkeleton({
children: [ children: [
// Header skeleton (avatar + name + role) // Header skeleton (avatar + name + role)
MyCard( MyCard(
borderRadiusAll: 5, borderRadiusAll: 8,
paddingAll: 16, paddingAll: 16,
margin: MySpacing.bottom(16), margin: MySpacing.bottom(16),
shadow: MyShadow(elevation: 2), shadow: MyShadow(elevation: 2),
@ -686,7 +465,7 @@ static Widget _buildDetailRowSkeleton({
(_) => Column( (_) => Column(
children: [ children: [
MyCard( MyCard(
borderRadiusAll: 5, borderRadiusAll: 8,
paddingAll: 16, paddingAll: 16,
margin: MySpacing.bottom(16), margin: MySpacing.bottom(16),
shadow: MyShadow(elevation: 2), shadow: MyShadow(elevation: 2),
@ -773,7 +552,7 @@ static Widget _buildDetailRowSkeleton({
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(3, (floorIndex) { children: List.generate(3, (floorIndex) {
return MyCard( return MyCard(
borderRadiusAll: 5, borderRadiusAll: 8,
paddingAll: 5, paddingAll: 5,
margin: MySpacing.bottom(10), margin: MySpacing.bottom(10),
shadow: MyShadow(elevation: 1.5), shadow: MyShadow(elevation: 1.5),
@ -787,7 +566,7 @@ static Widget _buildDetailRowSkeleton({
width: 160, width: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
MySpacing.height(10), MySpacing.height(10),
@ -809,7 +588,7 @@ static Widget _buildDetailRowSkeleton({
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
MySpacing.height(8), MySpacing.height(8),
@ -838,7 +617,7 @@ static Widget _buildDetailRowSkeleton({
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: borderRadius:
BorderRadius.circular(5), BorderRadius.circular(4),
), ),
), ),
), ),
@ -863,7 +642,7 @@ static Widget _buildDetailRowSkeleton({
static Widget chartSkeletonLoader() { static Widget chartSkeletonLoader() {
return MyCard.bordered( return MyCard.bordered(
paddingAll: 16, paddingAll: 16,
borderRadiusAll: 5, borderRadiusAll: 12,
shadow: MyShadow( shadow: MyShadow(
elevation: 1.5, elevation: 1.5,
position: MyShadowPosition.bottom, position: MyShadowPosition.bottom,
@ -878,7 +657,7 @@ static Widget _buildDetailRowSkeleton({
width: 180, width: 180,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -907,7 +686,7 @@ static Widget _buildDetailRowSkeleton({
height: 14, height: 14,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
); );
}), }),
@ -925,7 +704,7 @@ static Widget _buildDetailRowSkeleton({
width: 90, width: 90,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
); );
@ -956,7 +735,7 @@ static Widget _buildDetailRowSkeleton({
width: 160, width: 160,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
@ -988,7 +767,7 @@ static Widget _buildDetailRowSkeleton({
width: 100, width: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
const SizedBox(height: 6), const SizedBox(height: 6),
@ -997,7 +776,7 @@ static Widget _buildDetailRowSkeleton({
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
], ],
@ -1010,7 +789,7 @@ static Widget _buildDetailRowSkeleton({
width: 30, width: 30,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
const SizedBox(width: 6), const SizedBox(width: 6),
@ -1037,7 +816,7 @@ static Widget _buildDetailRowSkeleton({
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
@ -1046,7 +825,7 @@ static Widget _buildDetailRowSkeleton({
width: 140, width: 140,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
], ],
@ -1056,7 +835,7 @@ static Widget _buildDetailRowSkeleton({
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(4),
), ),
), ),
], ],
@ -1068,10 +847,8 @@ static Widget _buildDetailRowSkeleton({
} }
static Widget documentSkeletonLoader() { static Widget documentSkeletonLoader() {
return ListView.builder( return Column(
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 0), children: List.generate(5, (index) {
itemCount: 5,
itemBuilder: (context, index) {
return Column( return Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
@ -1084,7 +861,7 @@ static Widget _buildDetailRowSkeleton({
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
), ),
@ -1096,7 +873,7 @@ static Widget _buildDetailRowSkeleton({
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(5), borderRadius: BorderRadius.circular(12),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.05), color: Colors.black.withOpacity(0.05),
@ -1114,7 +891,7 @@ static Widget _buildDetailRowSkeleton({
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(5), borderRadius: BorderRadius.circular(8),
), ),
child: const Icon(Icons.description, child: const Icon(Icons.description,
color: Colors.transparent), // invisible icon color: Colors.transparent), // invisible icon
@ -1162,7 +939,7 @@ static Widget _buildDetailRowSkeleton({
), ),
], ],
); );
}, }),
); );
} }
@ -1178,7 +955,7 @@ static Widget _buildDetailRowSkeleton({
padding: const EdgeInsets.all(20), padding: const EdgeInsets.all(20),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(10),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.06), color: Colors.black.withOpacity(0.06),
@ -1235,7 +1012,7 @@ static Widget _buildDetailRowSkeleton({
width: 60, width: 60,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(12),
), ),
); );
}), }),
@ -1289,7 +1066,7 @@ static Widget _buildDetailRowSkeleton({
height: 40, height: 40,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(8),
), ),
), ),
const SizedBox(width: 12), const SizedBox(width: 12),
@ -1335,7 +1112,7 @@ static Widget _buildDetailRowSkeleton({
return Column( return Column(
children: List.generate(4, (index) { children: List.generate(4, (index) {
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 5, borderRadiusAll: 12,
paddingAll: 10, paddingAll: 10,
margin: MySpacing.bottom(12), margin: MySpacing.bottom(12),
shadow: MyShadow(elevation: 3), shadow: MyShadow(elevation: 3),
@ -1407,7 +1184,7 @@ static Widget _buildDetailRowSkeleton({
static Widget employeeListCollapsedSkeletonLoader() { static Widget employeeListCollapsedSkeletonLoader() {
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 5, borderRadiusAll: 4,
paddingAll: 8, paddingAll: 8,
child: ShimmerEffect( child: ShimmerEffect(
child: Column( child: Column(
@ -1479,7 +1256,7 @@ static Widget _buildDetailRowSkeleton({
static Widget dailyProgressReportSkeletonLoader() { static Widget dailyProgressReportSkeletonLoader() {
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 5, borderRadiusAll: 4,
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,
@ -1514,7 +1291,7 @@ static Widget _buildDetailRowSkeleton({
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: List.generate(3, (index) { children: List.generate(3, (index) {
return MyCard.bordered( return MyCard.bordered(
borderRadiusAll: 5, borderRadiusAll: 12,
paddingAll: 16, paddingAll: 16,
margin: MySpacing.bottom(12), margin: MySpacing.bottom(12),
shadow: MyShadow(elevation: 3), shadow: MyShadow(elevation: 3),
@ -1573,7 +1350,7 @@ static Widget _buildDetailRowSkeleton({
width: 120, width: 120,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
Container( Container(
@ -1581,7 +1358,7 @@ static Widget _buildDetailRowSkeleton({
width: 80, width: 80,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
], ],
@ -1595,7 +1372,7 @@ static Widget _buildDetailRowSkeleton({
width: 100, width: 100,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
const Spacer(), const Spacer(),
@ -1604,7 +1381,7 @@ static Widget _buildDetailRowSkeleton({
width: 50, width: 50,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(6),
), ),
), ),
], ],
@ -1620,7 +1397,7 @@ static Widget _buildDetailRowSkeleton({
return MyCard.bordered( return MyCard.bordered(
margin: MySpacing.only(bottom: 12), margin: MySpacing.only(bottom: 12),
paddingAll: 12, paddingAll: 12,
borderRadiusAll: 5, borderRadiusAll: 12,
shadow: MyShadow( shadow: MyShadow(
elevation: 1.5, elevation: 1.5,
position: MyShadowPosition.bottom, position: MyShadowPosition.bottom,
@ -1703,8 +1480,9 @@ static Widget _buildDetailRowSkeleton({
return MyCard.bordered( return MyCard.bordered(
margin: MySpacing.only(bottom: 12), margin: MySpacing.only(bottom: 12),
paddingAll: 16, paddingAll: 16,
borderRadiusAll: 5, borderRadiusAll: 16,
shadow: MyShadow( shadow: MyShadow(
elevation: 1.5,
position: MyShadowPosition.bottom, position: MyShadowPosition.bottom,
), ),
child: ShimmerEffect( child: ShimmerEffect(
@ -1858,7 +1636,7 @@ static Widget _buildDetailRowSkeleton({
// Aging Stacked Bar Placeholder // Aging Stacked Bar Placeholder
ClipRRect( ClipRRect(
borderRadius: BorderRadius.circular(5), borderRadius: BorderRadius.circular(8),
child: Row( child: Row(
children: List.generate( children: List.generate(
4, 4,
@ -2067,29 +1845,42 @@ static Widget _buildDetailRowSkeleton({
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
// Legend/Details Placeholder // Legend/Details Placeholder
// Aging Legend Placeholders Expanded(
Wrap( child: Column(
spacing: 12, crossAxisAlignment: CrossAxisAlignment.start,
runSpacing: 8,
children: List.generate( children: List.generate(
4, 3,
(index) => Row( (index) => Padding(
mainAxisSize: MainAxisSize.min, padding: const EdgeInsets.only(bottom: 8.0),
child: Row(
mainAxisAlignment:
MainAxisAlignment.spaceBetween,
children: [
Row(
children: [ children: [
Container( Container(
width: 10, width: 8,
height: 10, height: 8,
margin:
const EdgeInsets.only(right: 8),
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.grey.shade300, color: Colors.grey.shade300,
shape: BoxShape.circle)), shape: BoxShape.circle)),
const SizedBox(width: 6),
Container( Container(
height: 12, height: 12,
width: 115, // Reduced from 120 width: 80,
color: Colors.grey.shade300), color: Colors.grey.shade300),
], ],
),
Container(
height: 14,
width: 50,
color: Colors.grey.shade300),
],
),
)), )),
), ),
),
], ],
), ),
const SizedBox(height: 16), const SizedBox(height: 16),

View File

@ -1,9 +1,8 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
class PillTabBar extends StatefulWidget { class PillTabBar extends StatelessWidget {
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;
@ -14,7 +13,6 @@ class PillTabBar extends StatefulWidget {
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,
@ -22,81 +20,65 @@ class PillTabBar extends StatefulWidget {
this.onTap, this.onTap,
}) : super(key: key); }) : super(key: key);
@override
State<PillTabBar> createState() => _PillTabBarState();
}
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// Dynamic horizontal padding between tabs
final screenWidth = MediaQuery.of(context).size.width;
final tabSpacing = (screenWidth / (tabs.length * 12)).clamp(8.0, 24.0);
return Padding( return Padding(
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
child: Container( child: Container(
height: widget.height, height: height,
decoration: BoxDecoration( decoration: BoxDecoration(
color: Colors.white, color: Colors.white,
borderRadius: BorderRadius.circular(widget.height / 2), borderRadius: BorderRadius.circular(height / 2),
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
),
],
), ),
child: TabBar( child: TabBar(
controller: widget.controller, controller: controller,
isScrollable: true, // important for dynamic spacing
indicatorSize: TabBarIndicatorSize.tab,
indicator: BoxDecoration( indicator: BoxDecoration(
color: widget.indicatorColor.withOpacity(0.2), color: indicatorColor.withOpacity(0.2),
borderRadius: BorderRadius.circular(widget.height / 2), borderRadius: BorderRadius.circular(height / 2),
), ),
onTap: widget.onTap, indicatorSize: TabBarIndicatorSize.tab,
tabs: List.generate(widget.tabs.length, (index) { indicatorPadding: EdgeInsets.symmetric(
final isSelected = widget.controller.index == index; horizontal: tabSpacing / 2,
vertical: 4,
return AnimatedContainer(
duration: const Duration(milliseconds: 200),
padding: EdgeInsets.symmetric(
horizontal:
isSelected ? 12 : 6, // reduce padding for unselected tabs
), ),
child: Row( labelColor: selectedColor,
mainAxisSize: MainAxisSize.min, unselectedLabelColor: unselectedColor,
children: [ labelStyle: const TextStyle(
Icon( fontWeight: FontWeight.bold,
widget.icons[index], fontSize: 13,
size: isSelected ? 18 : 16,
color: isSelected
? widget.selectedColor
: widget.unselectedColor,
), ),
if (isSelected) ...[ unselectedLabelStyle: const TextStyle(
const SizedBox(width: 4),
Text(
widget.tabs[index],
style: TextStyle(
fontSize: 12,
fontWeight: FontWeight.w500, fontWeight: FontWeight.w500,
color: widget.selectedColor, fontSize: 13,
),
tabs: tabs
.map(
(text) => Tab(
child: Padding(
padding: EdgeInsets.symmetric(horizontal: tabSpacing),
child: Text(
text,
overflow: TextOverflow.ellipsis,
maxLines: 2,
), ),
), ),
],
],
), ),
); )
}), .toList(),
)), onTap: onTap,
),
),
); );
} }
} }

View File

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

View File

@ -24,11 +24,9 @@ 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,
@ -84,6 +82,10 @@ 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
@ -374,7 +376,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
} }
} }
Future<void> _onAssignTaskPressed() async { void _onAssignTaskPressed() {
final selectedTeam = controller.selectedEmployees; final selectedTeam = controller.selectedEmployees;
if (selectedTeam.isEmpty) { if (selectedTeam.isEmpty) {
@ -415,20 +417,14 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
return; return;
} }
final success = await controller.assignDailyTask( 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(), taskTeam: selectedTeam.map((e) => e.id).toList(), // pass IDs
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

@ -39,9 +39,7 @@ class _UserDocumentFilterBottomSheetState
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',
@ -55,8 +53,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?.toIso8601String(), 'startDate': docController.startDate.value,
'endDate': docController.endDate.value?.toIso8601String(), 'endDate': docController.endDate.value,
if (docController.isVerified.value != null) if (docController.isVerified.value != null)
'isVerified': docController.isVerified.value, 'isVerified': docController.isVerified.value,
}; };

View File

@ -52,14 +52,6 @@ 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,
@ -225,6 +217,13 @@ 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",
@ -240,29 +239,6 @@ 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(
@ -286,9 +262,12 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
_gap(), _gap(),
_buildTextFieldSection( _buildTextFieldSection(
icon: Icons.confirmation_number_outlined, icon: Icons.confirmation_number_outlined,
title: "GST No.", title: "Transaction ID",
controller: controller.gstController, controller: controller.transactionIdController,
hint: "Enter GST No.", hint: "Enter Transaction ID",
validator: (v) => (v != null && v.isNotEmpty)
? Validators.transactionIdValidator(v)
: null,
), ),
_gap(), _gap(),
_buildTransactionDateField(), _buildTransactionDateField(),
@ -342,18 +321,12 @@ 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, icon: icon, title: title, requiredField: validator != null),
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.put(ExpenseDetailController()); Get.find<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) { if (expenseTransactionDate != null && selectedDate != null) {
final normalizedSelected = DateTime( final normalizedSelected = DateTime(
selectedDate.year, selectedDate.year,
selectedDate.month, selectedDate.month,

View File

@ -1,25 +0,0 @@
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

@ -1,119 +0,0 @@
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,6 +1,7 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:get/get.dart'; import 'package:get/get.dart';
import 'package: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';
@ -31,7 +32,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 (!AuthService.isTenantSelected) { } else if (!TenantService.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, dynamic>> _tabs; late List<Map<String, String>> _tabs;
bool _tabsInitialized = false; bool _tabsInitialized = false;
@override @override
@ -62,13 +62,9 @@ class _AttendanceScreenState extends State<AttendanceScreen>
void _initializeTabs() async { void _initializeTabs() async {
final allTabs = [ final allTabs = [
{'label': "Today's", 'value': 'todaysAttendance', 'icon': Icons.today}, {'label': "Today's", 'value': 'todaysAttendance'},
{'label': "Logs", 'value': 'attendanceLogs', 'icon': Icons.list_alt}, {'label': "Logs", 'value': 'attendanceLogs'},
{ {'label': "Regularization", 'value': 'regularizationRequests'},
'label': "Regularization",
'value': 'regularizationRequests',
'icon': Icons.edit
},
]; ];
final hasRegularizationPermission = final hasRegularizationPermission =
@ -310,11 +306,7 @@ 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: _tabs.map((e) => e['label']!).toList(),
_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,33 +123,14 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
Widget _buildWelcomeText() { Widget _buildWelcomeText() {
return Column( return Column(
children: [ children: [
RichText( MyText(
textAlign: TextAlign.center, "Welcome to On Field Work",
text: TextSpan(
style: const TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w600, fontWeight: 600,
color: Colors.black87, color: Colors.black87,
textAlign: TextAlign.center,
), ),
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,
@ -273,11 +254,7 @@ 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( icon: Icon(Icons.arrow_back, size: 18, color: contentTheme.primary,),
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,33 +196,14 @@ class _WelcomeScreenState extends State<WelcomeScreen>
Widget _buildWelcomeText() { Widget _buildWelcomeText() {
return Column( return Column(
children: [ children: [
RichText( MyText(
textAlign: TextAlign.center, "Welcome to On Field Work",
text: TextSpan(
style: const TextStyle(
fontSize: 26, fontSize: 26,
fontWeight: FontWeight.w800, fontWeight: 800,
color: Colors.black87, color: Colors.black87,
textAlign: TextAlign.center,
), ),
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,6 +10,7 @@ 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});
@ -90,31 +91,12 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
child: Column( child: Column(
children: [ children: [
const SizedBox(height: 12), const SizedBox(height: 12),
RichText( MyText(
textAlign: TextAlign.center, "Welcome to On Field Work",
text: const TextSpan(
style: TextStyle(
fontSize: 24, fontSize: 24,
fontWeight: FontWeight.w800, fontWeight: 800,
color: Colors.black87, color: Colors.black87,
), textAlign: TextAlign.center,
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(
@ -335,8 +317,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: Icon(Icons.arrow_back,
Icon(Icons.arrow_back, size: 18, color: contentTheme.primary), 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'; // Unused import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart';
// import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart'; // Unused import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart'; import 'package:on_field_work/helpers/widgets/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.put(ProjectController()); final ProjectController projectController = Get.find<ProjectController>();
bool hasMpin = true; bool hasMpin = true;
@ -56,36 +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: cardRadius, borderRadius: BorderRadius.circular(5),
border: Border.all(color: Colors.black12.withOpacity(.04)), border: Border.all(color: Colors.black12.withOpacity(.04)),
boxShadow: cardShadow, boxShadow: [
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: MyText.titleMedium( child: Text(
title, title,
fontWeight: 700, style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87, color: Colors.black87,
), ),
),
); );
} }
@ -121,33 +120,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
end: Alignment.bottomRight, end: Alignment.bottomRight,
), ),
), ),
child: Column( child: const Text(
crossAxisAlignment: CrossAxisAlignment.start, 'No attendance data available',
mainAxisSize: MainAxisSize.min, style: TextStyle(color: Colors.white),
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),
),
],
), ),
); );
} }
@ -162,12 +137,6 @@ 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(
@ -216,15 +185,19 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
], ],
), ),
MySpacing.height(12), const SizedBox(height: 12),
Text( Text(
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.',
style: const TextStyle( style: const TextStyle(
color: Colors.white70, color: Colors.white70,
fontSize: 13, fontSize: 13,
), ),
), ),
MySpacing.height(12), const SizedBox(height: 12),
Row( Row(
mainAxisAlignment: MainAxisAlignment.end, mainAxisAlignment: MainAxisAlignment.end,
children: [ children: [
@ -263,7 +236,8 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final bool projectSelected = projectController.selectedProject != null; final bool projectSelected = projectController.selectedProject != null;
const List<String> cardOrder = [ // these are String constants from permission_constants.dart
final List<String> cardOrder = [
MenuItems.attendance, MenuItems.attendance,
MenuItems.employees, MenuItems.employees,
MenuItems.directory, MenuItems.directory,
@ -306,7 +280,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
child: Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ children: [
_sectionTitle('Modules'), const Text(
'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(
@ -331,7 +312,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
GridView.builder( GridView.builder(
shrinkWrap: true, shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(), // Important! physics: const NeverScrollableScrollPhysics(),
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,
@ -345,9 +326,8 @@ 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.id == MenuItems.attendance ? true : projectSelected; item.name == 'Attendance' ? true : projectSelected;
return GestureDetector( return GestureDetector(
onTap: () { onTap: () {
@ -391,7 +371,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
color: color:
isEnabled ? cardMeta.color : Colors.grey.shade300, isEnabled ? cardMeta.color : Colors.grey.shade300,
), ),
MySpacing.height(6), const SizedBox(height: 6),
Padding( Padding(
padding: const EdgeInsets.symmetric(horizontal: 2), padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text( child: Text(
@ -433,14 +413,10 @@ 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.projectSelectorSkeleton(); return SkeletonLoaders.dashboardCardsSkeleton(
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,
@ -469,10 +445,15 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
color: Colors.blue, color: Colors.blue,
size: 20, size: 20,
), ),
MySpacing.width(12), const SizedBox(width: 12),
Expanded( Expanded(
child: Text( child: Text(
selectedProjectName, projects
.firstWhereOrNull(
(p) => p.id == selectedId,
)
?.name ??
'Select Project',
style: const TextStyle( style: const TextStyle(
fontSize: 15, fontSize: 15,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w600,
@ -517,17 +498,17 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
child: Column( child: Column(
children: [ children: [
const TextField( TextField(
decoration: InputDecoration( decoration: InputDecoration(
hintText: 'Search project...', hintText: 'Search project...',
isDense: true, isDense: true,
prefixIcon: Icon(Icons.search), prefixIcon: const Icon(Icons.search),
border: OutlineInputBorder( border: OutlineInputBorder(
borderRadius: BorderRadius.all(Radius.circular(5)), borderRadius: BorderRadius.circular(5),
), ),
), ),
), ),
MySpacing.height(10), const SizedBox(height: 10),
Expanded( Expanded(
child: ListView.builder( child: ListView.builder(
itemCount: projects.length, itemCount: projects.length,
@ -553,37 +534,31 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
), ),
); );
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
// Build (MODIFIED FOR FIXED HEADER) // Build
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------
@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: Stack( child: SingleChildScrollView(
children: [ padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
// Main content
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
child: Column( child: Column(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
_projectSelector(), _projectSelector(),
MySpacing.height(20), MySpacing.height(20),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_quickActions(), _quickActions(),
MySpacing.height(20), MySpacing.height(20),
_dashboardModules(), _dashboardModules(),
MySpacing.height(20), MySpacing.height(20),
_sectionTitle('Reports & Analytics'), _sectionTitle('Reports & Analytics'),
CompactPurchaseInvoiceDashboard(),
MySpacing.height(20),
CollectionsHealthWidget(),
MySpacing.height(20),
_cardWrapper( _cardWrapper(
child: ExpenseTypeReportChart(), child: ExpenseTypeReportChart(),
), ),
@ -595,20 +570,15 @@ Widget build(BuildContext context) {
_cardWrapper( _cardWrapper(
child: MonthlyExpenseDashboardChart(), child: MonthlyExpenseDashboardChart(),
), ),
MySpacing.height(80), // give space under content MySpacing.height(20),
], ],
), ),
), ),
), ),
],
),
),
],
),
),
); );
}
} }
}
class _DashboardCardMeta { class _DashboardCardMeta {
final IconData icon; final IconData icon;
final Color color; final Color color;

View File

@ -70,7 +70,6 @@ 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,7 +46,6 @@ 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(
@ -74,10 +73,6 @@ 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,8 +424,6 @@ 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,7 +526,6 @@ 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,
), ),
); );
@ -539,7 +538,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,7 +4,6 @@ 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;
@ -17,11 +16,14 @@ 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);
} }
@ -31,8 +33,11 @@ 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;
@ -40,13 +45,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(
@ -60,20 +65,63 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
), ),
), ),
), ),
// === Main Content Area ===
SafeArea( SafeArea(
top: false, top: false,
bottom: true, bottom: true,
child: Column( child: Column(
children: [ children: [
PillTabBar( // 🛑 NEW: The Modern TabBar Implementation 🛑
controller: _tabController, Padding(
tabs: const ["Details", "Documents"], padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
icons: const [Icons.person, Icons.folder], child: Container(
selectedColor: primaryColor, height: 48, // Define a specific height for the TabBar container
unselectedColor: Colors.grey.shade600, decoration: BoxDecoration(
indicatorColor: primaryColor, color: Colors.white,
height: 48, borderRadius: BorderRadius.circular(24.0), // Rounded corners for a chip-like look
boxShadow: [
BoxShadow(
color: Colors.grey.withOpacity(0.15),
blurRadius: 4,
offset: const Offset(0, 2),
), ),
],
),
child: TabBar(
controller: _tabController,
// Style the indicator as a subtle pill/chip
indicator: BoxDecoration(
color: primaryColor.withOpacity(0.1), // Light background color for the selection
borderRadius: BorderRadius.circular(24.0),
),
indicatorSize: TabBarIndicatorSize.tab,
// The padding is used to slightly shrink the indicator area
indicatorPadding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
// Text styling
labelColor: primaryColor, // Selected text color is primary
unselectedLabelColor: Colors.grey.shade600, // Unselected text color is darker grey
labelStyle: const TextStyle(
fontWeight: FontWeight.bold,
fontSize: 15,
),
unselectedLabelStyle: const TextStyle(
fontWeight: FontWeight.w500,
fontSize: 15,
),
// Tabs (No custom widget needed, just use the built-in Tab)
tabs: const [
Tab(text: "Details"),
Tab(text: "Documents"),
],
// Setting this to zero removes the default underline
dividerColor: Colors.transparent,
),
),
),
// 🛑 TabBarView (The Content) 🛑
Expanded( Expanded(
child: TabBarView( child: TabBarView(
controller: _tabController, controller: _tabController,

View File

@ -7,7 +7,9 @@ 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';
@ -28,27 +30,56 @@ 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 {
await _initEmployees();
_searchController.addListener(() { _searchController.addListener(() {
_employeeController.searchEmployees(_searchController.text); _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();
_employeeController.searchEmployees(_searchController.text); _filterEmployees(_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,
@ -90,7 +121,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Employees", title: "Employees",
backgroundColor: appBarColor, backgroundColor: appBarColor,
projectName: " All Projects", projectName: Get.find<ProjectController>().selectedProject?.name ??
'Select Project',
onBackPressed: () => Get.offNamed('/dashboard'), onBackPressed: () => Get.offNamed('/dashboard'),
), ),
body: Stack( body: Stack(
@ -112,7 +144,11 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
// Main content // Main content
SafeArea( SafeArea(
child: Obx(() { child: GetBuilder<EmployeesScreenController>(
init: _employeeController,
tag: 'employee_screen_controller',
builder: (_) {
_filterEmployees(_searchController.text);
return MyRefreshIndicator( return MyRefreshIndicator(
onRefresh: _refreshEmployees, onRefresh: _refreshEmployees,
child: SingleChildScrollView( child: SingleChildScrollView(
@ -122,7 +158,10 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
_buildSearchField(), Padding(
padding: MySpacing.x(15),
child: _buildSearchField(),
),
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
Padding( Padding(
padding: MySpacing.x(flexSpacing), padding: MySpacing.x(flexSpacing),
@ -132,7 +171,8 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
), ),
), ),
); );
}), },
),
), ),
], ],
), ),
@ -198,6 +238,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
size: 20, color: Colors.grey), size: 20, color: Colors.grey),
onPressed: () { onPressed: () {
_searchController.clear(); _searchController.clear();
_filterEmployees('');
}, },
); );
}, },
@ -214,11 +255,13 @@ 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,
@ -234,7 +277,10 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
shape: RoundedRectangleBorder( shape: RoundedRectangleBorder(
borderRadius: BorderRadius.circular(5)), borderRadius: BorderRadius.circular(5)),
itemBuilder: (context) { itemBuilder: (context) {
return [ List<PopupMenuEntry<int>> menuItems = [];
// Section: Actions
menuItems.add(
const PopupMenuItem<int>( const PopupMenuItem<int>(
enabled: false, enabled: false,
height: 30, height: 30,
@ -244,6 +290,10 @@ 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(
@ -267,7 +317,9 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
}); });
}, },
), ),
]; );
return menuItems;
}, },
), ),
), ),
@ -277,6 +329,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
} }
Widget _buildEmployeeList() { Widget _buildEmployeeList() {
return Obx(() {
if (_employeeController.isLoading.value) { if (_employeeController.isLoading.value) {
return ListView.separated( return ListView.separated(
shrinkWrap: true, shrinkWrap: true,
@ -287,8 +340,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
); );
} }
final employees = _employeeController.filteredEmployees; final employees = _filteredEmployees;
if (employees.isEmpty) { if (employees.isEmpty) {
return Padding( return Padding(
padding: const EdgeInsets.only(top: 60), padding: const EdgeInsets.only(top: 60),
@ -343,19 +395,22 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
_buildLinkRow( _buildLinkRow(
icon: Icons.phone_outlined, icon: Icons.phone_outlined,
text: e.phoneNumber, text: e.phoneNumber,
onTap: () => LauncherUtils.launchPhone(e.phoneNumber), onTap: () =>
LauncherUtils.launchPhone(e.phoneNumber),
onLongPress: () => LauncherUtils.copyToClipboard( onLongPress: () => LauncherUtils.copyToClipboard(
e.phoneNumber, e.phoneNumber,
typeLabel: 'Phone')), typeLabel: 'Phone')),
], ],
), ),
), ),
const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16), const Icon(Icons.arrow_forward_ios,
color: Colors.grey, size: 16),
], ],
), ),
); );
}, },
); );
});
} }
Widget _buildLinkRow({ Widget _buildLinkRow({

View File

@ -36,10 +36,6 @@ 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;
@ -73,10 +69,6 @@ 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();
@ -376,7 +368,6 @@ 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,
@ -388,7 +379,6 @@ 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,
@ -396,13 +386,12 @@ 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: 8, bottom: MediaQuery.of(context).viewPadding.bottom + 20,
left: 16, left: 16, right: 16, top: 8,
right: 16,
top: 8,
), ),
child: content, child: content,
), ),
@ -460,7 +449,6 @@ 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,
@ -471,10 +459,20 @@ class _ManageReportingBottomSheetState
MyText.bodyMedium(label, fontWeight: 600), MyText.bodyMedium(label, fontWeight: 600),
MySpacing.height(8), MySpacing.height(8),
_searchBar( // Search field
TextField(
controller: controller, controller: controller,
focusNode: focusNode, decoration: InputDecoration(
hint: "Type to search employees...", hintText: "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
@ -569,10 +567,19 @@ class _ManageReportingBottomSheetState
children: [ children: [
MyText.bodyMedium("Select Employee *", fontWeight: 600), MyText.bodyMedium("Select Employee *", fontWeight: 600),
MySpacing.height(8), MySpacing.height(8),
_searchBar( TextField(
controller: _selectEmployeeController, controller: _selectEmployeeController,
focusNode: _mainEmployeeFocus, decoration: InputDecoration(
hint: "Type to search employee...", hintText: "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();
@ -634,55 +641,4 @@ 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,12 +11,14 @@ 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';
@ -35,14 +37,15 @@ 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());
// Removed local employeeInfo, canSubmit, and _checkedPermission EmployeeInfo? employeeInfo;
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
@ -51,17 +54,41 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
super.dispose(); super.dispose();
} }
// Removed _loadEmployeeInfo and _checkPermissionToSubmit void _loadEmployeeInfo() async {
final info = await LocalStorage.getEmployeeInfo();
employeeInfo = info;
}
void _checkPermissionToSubmit(ExpenseDetailModel expense) {
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id;
final nextStatusIds = expense.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: ${expense.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;
}
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final Color appBarColor = contentTheme.primary; final Color appBarColor = contentTheme.primary;
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFFF7F7F7), backgroundColor: const Color(0xFFF7F7F7),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Expense Details", title: "Expense Details",
projectName: " All Projects",
backgroundColor: appBarColor, backgroundColor: appBarColor,
onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'), onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'),
), ),
@ -92,7 +119,9 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
return Center(child: MyText.bodyMedium("No data to display.")); return Center(child: MyText.bodyMedium("No data to display."));
} }
// Permission logic moved to controller (no need for postFrameCallback here) WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
final statusColor = getExpenseStatusColor( final statusColor = getExpenseStatusColor(
expense.status.name, expense.status.name,
@ -106,7 +135,8 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
}, },
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),
@ -132,11 +162,9 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
Row( Row(
children: [ children: [
Column( Column(
crossAxisAlignment: crossAxisAlignment: CrossAxisAlignment.start,
CrossAxisAlignment.start,
children: [ children: [
MyText.bodyMedium('Amount', MyText.bodyMedium('Amount', fontWeight: 600),
fontWeight: 600),
const SizedBox(height: 4), const SizedBox(height: 4),
MyText.bodyLarge( MyText.bodyLarge(
formattedAmount, formattedAmount,
@ -195,16 +223,21 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
], ],
), ),
floatingActionButton: Obx(() { floatingActionButton: Obx(() {
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 const SizedBox.shrink(); return const SizedBox.shrink();
} }
// Removed _checkedPermission and its logic if (!_checkedPermission) {
_checkedPermission = true;
WidgetsBinding.instance.addPostFrameCallback((_) {
_checkPermissionToSubmit(expense);
});
}
if (!ExpensePermissionHelper.canEditExpense( if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
controller.employeeInfo, // Use controller's employeeInfo
expense)) {
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
@ -271,8 +304,9 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
controller.parsePermissionIds(rawPermissions); controller.parsePermissionIds(rawPermissions);
final isSubmitStatus = next.id == submitStatusId; final isSubmitStatus = next.id == submitStatusId;
final isCreatedByCurrentUser = controller.employeeInfo?.id == final isCreatedByCurrentUser =
expense.createdBy.id; employeeInfo?.id == expense.createdBy.id;
if (isSubmitStatus) return isCreatedByCurrentUser; if (isSubmitStatus) return isCreatedByCurrentUser;
return permissionController.hasAnyPermission(parsedPermissions); return permissionController.hasAnyPermission(parsedPermissions);
}).map((next) { }).map((next) {
@ -283,7 +317,8 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
); );
}), }),
); );
} }
Widget _statusButton(BuildContext context, ExpenseDetailController controller, Widget _statusButton(BuildContext context, ExpenseDetailController controller,
ExpenseDetailModel expense, dynamic next) { ExpenseDetailModel expense, dynamic next) {

View File

@ -95,7 +95,6 @@ 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'),
), ),
@ -134,10 +133,6 @@ 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,7 +54,6 @@ 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,6 +14,7 @@ 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});
@ -58,8 +59,7 @@ class _FinanceScreenState extends State<FinanceScreen>
backgroundColor: const Color(0xFFF8F9FA), backgroundColor: const Color(0xFFF8F9FA),
appBar: CustomAppBar( appBar: CustomAppBar(
title: "Finance", title: "Finance",
projectName: " All Projects", onBackPressed: () => Get.offAllNamed( '/dashboard' ),
onBackPressed: () => Get.offAllNamed('/dashboard'),
backgroundColor: appBarColor, backgroundColor: appBarColor,
), ),
body: Stack( body: Stack(

View File

@ -114,7 +114,6 @@ 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(
@ -218,7 +217,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
return const SizedBox.shrink(); return const SizedBox.shrink();
} }
if (!_checkedPermission && employeeInfo != null) { if (!_checkedPermission && request != null && employeeInfo != null) {
_checkedPermission = true; _checkedPermission = true;
_checkPermissionToSubmit(request); _checkPermissionToSubmit(request);
} }

View File

@ -104,7 +104,6 @@ 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,
), ),
@ -143,10 +142,6 @@ 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,
@ -188,13 +183,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
return canCreate return canCreate
? FloatingActionButton.extended( ? FloatingActionButton.extended(
backgroundColor: contentTheme.primary, backgroundColor: contentTheme.primary,
onPressed: () { onPressed: showPaymentRequestBottomSheet,
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

@ -1,306 +0,0 @@
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,16 +10,11 @@ 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;
@ -41,213 +36,51 @@ 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() {
_tabs.add(_InfraTab( // Profile tab is always added
name: "Profile", _tabs.add(_InfraTab(name: "Profile", view: _buildProfileTab()));
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(_InfraTab( _tabs.add(
_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(_InfraTab( _tabs.add(
_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 _buildTeamTab() {
return Obx(() {
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() { Widget _buildProfileTab() {
final controller =
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
return Obx(() { return Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator()); return const Center(child: CircularProgressIndicator());
@ -322,27 +155,23 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
_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',
@ -352,8 +181,7 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
if (data.contactPerson != null) { if (data.contactPerson != null) {
LauncherUtils.launchPhone(data.contactPerson!); LauncherUtils.launchPhone(data.contactPerson!);
} }
}, }),
),
], ],
); );
} }
@ -366,22 +194,20 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
_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: () => LauncherUtils.launchPhone(promoter.contactNumber ?? ""), onTap: () =>
), 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 ?? "")),
),
], ],
); );
} }
@ -392,24 +218,19 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
titleIcon: Icons.engineering_outlined, titleIcon: Icons.engineering_outlined,
children: [ children: [
_buildDetailRow( _buildDetailRow(
icon: Icons.person_outline, icon: Icons.person_outline, label: 'Name', value: pmc.name ?? "-"),
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 ?? "")),
),
], ],
); );
} }
@ -430,9 +251,7 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
children: [ children: [
Container( Container(
padding: const EdgeInsets.all(8), padding: const EdgeInsets.all(8), child: Icon(icon, size: 20)),
child: Icon(icon, size: 20),
),
MySpacing.width(16), MySpacing.width(16),
Expanded( Expanded(
child: Column( child: Column(
@ -510,19 +329,6 @@ 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(
@ -543,7 +349,6 @@ 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,
@ -566,12 +371,7 @@ 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({ _InfraTab({required this.name, required this.view});
required this.name,
required this.icon,
required this.view,
});
} }

View File

@ -11,7 +11,6 @@ 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});
@ -246,7 +245,7 @@ class _InfraProjectScreenState extends State<InfraProjectScreen> with UIMixin {
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return Center(child: SkeletonLoaders.serviceProjectListSkeletonLoader()); return const Center(child: CircularProgressIndicator());
} }
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/auth_service.dart'; import 'package:on_field_work/helpers/services/tenant_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,6 +24,7 @@ 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;
@ -45,16 +46,18 @@ 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<LayoutController>( return GetBuilder(
init: controller, init: controller,
builder: (_) { builder: (_) {
return _buildScaffold(context); return (screenMT.isMobile || screenMT.isTablet)
? _buildScaffold(context, isMobile: true)
: _buildScaffold(context);
}, },
); );
}); });
} }
Widget _buildScaffold(BuildContext context) { Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
final primaryColor = contentTheme.primary; final primaryColor = contentTheme.primary;
return Scaffold( return Scaffold(
@ -63,10 +66,11 @@ class _LayoutState extends State<Layout> with UIMixin {
floatingActionButton: widget.floatingActionButton, floatingActionButton: widget.floatingActionButton,
body: Column( body: Column(
children: [ children: [
// Solid primary background area
Container( Container(
width: double.infinity, width: double.infinity,
color: primaryColor, color: primaryColor,
child: _buildHeaderContent(), child: _buildHeaderContent(isMobile),
), ),
Expanded( Expanded(
child: Container( child: Container(
@ -87,27 +91,27 @@ class _LayoutState extends State<Layout> with UIMixin {
top: false, top: false,
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.translucent, behavior: HitTestBehavior.translucent,
onTap: () => onTap: () {},
FocusScope.of(context).unfocus(), child: SingleChildScrollView(
child: widget.child ?? key: controller.scrollKey,
const SizedBox.shrink(), padding: EdgeInsets.zero,
child: widget.child,
),
), ),
), ),
), ),
), ),
], ],
), ));
);
} }
Widget _buildHeaderContent() { Widget _buildHeaderContent(bool isMobile) {
final selectedTenant = AuthService.currentTenant; final selectedTenant = TenantService.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: 10), margin: const EdgeInsets.only(bottom: 18),
width: double.infinity, width: double.infinity,
padding: const EdgeInsets.all(12), padding: const EdgeInsets.all(12),
decoration: BoxDecoration( decoration: BoxDecoration(
@ -135,7 +139,7 @@ class _LayoutState extends State<Layout> with UIMixin {
), ),
// Beta badge // Beta badge
if (isBeta) if (ApiEndpoints.baseUrl.contains("stage"))
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/auth_service.dart'; import 'package:on_field_work/helpers/services/tenant_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 = AuthService.currentTenant; final selectedTenant = TenantService.currentTenant;
final sortedTenants = List.of(tenants); final sortedTenants = List.of(tenants);
if (selectedTenant != null) { if (selectedTenant != null) {

View File

@ -1,291 +0,0 @@
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,9 +3,8 @@ 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';
@ -14,120 +13,84 @@ 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';
import 'package:on_field_work/view/mandatory_update_screen.dart'; class MyApp extends StatelessWidget {
class MyApp extends StatefulWidget {
final bool isOffline; 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";
} }
if (LocalStorage.getIsMpin()) { final bool hasMpin = 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, stack) { } catch (e, stacktrace) {
logSafe( logSafe("Error determining initial route",
"Initial route ERROR", level: LogLevel.error, error: e, stackTrace: stacktrace);
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
/// Offline Overlay (Blocking) Widget _buildConnectivityOverlay(BuildContext context) {
/// ------------------------- // If not offline, return an empty widget.
Widget _buildOfflineOverlay() { if (!isOffline) return const SizedBox.shrink();
// Otherwise, return a full-screen overlay.
return Directionality( return Directionality(
textDirection: AppTheme.textDirection, textDirection: AppTheme.textDirection,
child: Scaffold( child: Scaffold(
backgroundColor: Colors.grey.shade200, backgroundColor:
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(Icons.cloud_off, size: 100, color: Colors.red.shade600), Icon(
const SizedBox(height: 20), Icons.cloud_off,
color: Colors.red.shade700, // Prominent color
size: 100,
),
const SizedBox(height: 24),
const Text( const Text(
"You Are Offline", "You Are Offline",
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold), style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
), ),
const SizedBox(height: 10), ),
const SizedBox(height: 8),
const Padding( const Padding(
padding: EdgeInsets.symmetric(horizontal: 32), padding: EdgeInsets.symmetric(horizontal: 40.0),
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(fontSize: 16, color: Colors.black54), style: TextStyle(
fontSize: 16,
color: Colors.black54,
), ),
), ),
),
const SizedBox(height: 32),
// Optional: Add a button for the user to potentially refresh/retry
// ElevatedButton(
// onPressed: () {
// // Add logic to re-check connectivity or navigate (if possible)
// },
// child: const Text("RETRY"),
// ),
], ],
), ),
), ),
@ -135,28 +98,21 @@ class _MyAppState extends State<MyApp> {
); );
} }
/// -------------------------
/// 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()),
@ -171,19 +127,29 @@ class _MyAppState extends State<MyApp> {
navigatorKey: NavigationService.navigatorKey, navigatorKey: NavigationService.navigatorKey,
initialRoute: snapshot.data!, initialRoute: snapshot.data!,
getPages: getPageRoute(), getPages: getPageRoute(),
supportedLocales: Language.getLocales(), builder: (context, child) {
localizationsDelegates: const [ NavigationService.registerContext(context);
// 💡 REVISED: Use a Stack to place the offline overlay ON TOP of the app content.
// This allows the full-screen view to cover everything, including the main app content.
return Stack(
children: [
Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
),
// 2. The full-screen connectivity overlay, only visible when offline
_buildConnectivityOverlay(context),
],
);
},
localizationsDelegates: [
AppLocalizationsDelegate(context),
GlobalMaterialLocalizations.delegate, GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate, GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate, GlobalCupertinoLocalizations.delegate,
], ],
builder: (context, child) { supportedLocales: Language.getLocales(),
NavigationService.registerContext(context);
return Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
);
},
); );
}, },
); );

View File

@ -14,8 +14,6 @@ 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;
@ -334,8 +332,7 @@ class _ServiceProjectDetailsScreenState
Widget _buildTeamsTab() { Widget _buildTeamsTab() {
return Obx(() { return Obx(() {
if (controller.isTeamLoading.value) { if (controller.isTeamLoading.value) {
return Center( return const Center(child: CircularProgressIndicator());
child: SkeletonLoaders.serviceProjectListSkeletonLoader());
} }
if (controller.teamErrorMessage.value.isNotEmpty && if (controller.teamErrorMessage.value.isNotEmpty &&
@ -388,14 +385,7 @@ 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 InkWell( return Padding(
onTap: () {
// NAVIGATION TO EMPLOYEE DETAILS SCREEN
Get.to(() => EmployeeProfilePage(
employeeId: team.employee.id,
));
},
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 8), padding: const EdgeInsets.symmetric(vertical: 8),
child: Row( child: Row(
children: [ children: [
@ -426,7 +416,6 @@ class _ServiceProjectDetailsScreenState
), ),
], ],
), ),
),
); );
}).toList(), }).toList(),
], ],
@ -475,11 +464,6 @@ 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),
@ -491,9 +475,7 @@ class _ServiceProjectDetailsScreenState
child: Obx(() { child: Obx(() {
if (controller.isLoading.value && if (controller.isLoading.value &&
controller.projectDetail.value == null) { controller.projectDetail.value == null) {
return Center( return const Center(child: CircularProgressIndicator());
child: SkeletonLoaders
.serviceProjectListSkeletonLoader());
} }
if (controller.errorMessage.value.isNotEmpty && if (controller.errorMessage.value.isNotEmpty &&
controller.projectDetail.value == null) { controller.projectDetail.value == null) {

View File

@ -9,7 +9,6 @@ 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});
@ -265,9 +264,7 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
Expanded( Expanded(
child: Obx(() { child: Obx(() {
if (controller.isLoading.value) { if (controller.isLoading.value) {
return Center( return const Center(child: CircularProgressIndicator());
child: SkeletonLoaders
.serviceProjectListSkeletonLoader());
} }
final projects = controller.filteredProjects; final projects = controller.filteredProjects;

View File

@ -29,9 +29,6 @@ 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();
@ -42,7 +39,7 @@ class _SplashScreenState extends State<SplashScreen>
vsync: this, vsync: this,
); );
// Initial scale-in: from 0.5 to 1.0 (happens in the first 40% of the duration) // Initial scale-in: from 0.0 to 1.0 (happens in the first 40% of the duration)
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate( _scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation( CurvedAnimation(
parent: _controller, parent: _controller,
@ -59,15 +56,7 @@ class _SplashScreenState extends State<SplashScreen>
), ),
); );
// Shimmer/Gradient Animation: Moves the gradient horizontally from left to right // Floating effect: from 0.0 to 1.0 (loops repeatedly after initial animations)
_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,
@ -77,10 +66,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 and shimmer animation // After the initial scale/fade, switch to repeating the float animation
if (mounted) { if (mounted) {
_controller.repeat( _controller.repeat(
min: 0.4, // Keep repeat range for float animation min: 0.4, // Start repeat from the float interval
max: 1.0, max: 1.0,
reverse: true, reverse: true,
); );
@ -94,70 +83,6 @@ 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(
@ -173,6 +98,7 @@ 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,
@ -201,15 +127,6 @@ 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,24 +84,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
Padding( Padding(
padding: MySpacing.x(10), padding: MySpacing.x(10),
child: Obx(() { child: ServiceSelector(
// 1. Check if services are loading or empty
if (serviceController.isLoadingServices.value) {
return ServiceSelector(
controller: serviceController,
height: 40,
onSelectionChanged: (service) async {
// Empty handler when loading
},
);
}
if (serviceController.services.isEmpty) {
return const _EmptyServiceWidget();
}
// 2. Display ServiceSelector if services are available
return ServiceSelector(
controller: serviceController, controller: serviceController,
height: 40, height: 40,
onSelectionChanged: (service) async { onSelectionChanged: (service) async {
@ -114,8 +97,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
); );
} }
}, },
); ),
}),
), ),
MySpacing.height(flexSpacing), MySpacing.height(flexSpacing),
Padding( Padding(
@ -144,11 +126,12 @@ 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 const _EmptyDataCard( return Center(
title: "No Daily Tasks Found", child: MyText.bodySmall(
subtitle: "No progress reports are planned for the selected filter.", "No Progress Report Found",
fontWeight: 600,
),
); );
} }
@ -181,10 +164,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
.toList(); .toList();
if (buildings.isEmpty) { if (buildings.isEmpty) {
return const _EmptyDataCard( return Center(
title: "No Progress Report Found", child: MyText.bodySmall(
subtitle: "No Progress Report Found",
"No work is planned or completed for the selected service/project.", fontWeight: 600,
),
); );
} }
@ -263,11 +247,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
.dailyProgressPlanningInfraSkeleton(), .dailyProgressPlanningInfraSkeleton(),
) )
else if (!buildingLoaded || building.floors.isEmpty) else if (!buildingLoaded || building.floors.isEmpty)
const Padding( Padding(
padding: EdgeInsets.all(16.0), padding: const EdgeInsets.all(16.0),
child: _EmptyDataMessage( child: MyText.bodySmall(
message: "No Progress Report Found for this Project",
"No floors or work data found for this building.", fontWeight: 600,
), ),
) )
else else
@ -447,40 +431,33 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
.assignReportTask)) .assignReportTask))
IconButton( IconButton(
icon: const Icon( icon: const Icon(
Icons Icons.person_add_alt_1_rounded,
.person_add_alt_1_rounded,
color: Color.fromARGB( color: Color.fromARGB(
255, 46, 161, 233), 255, 46, 161, 233),
), ),
onPressed: () async { onPressed: () {
final pendingTask = final pendingTask =
(planned - completed) (planned - completed)
.clamp(0, planned) .clamp(0, planned)
.toInt(); .toInt();
// Wait until user closes bottom sheet showModalBottomSheet(
await showModalBottomSheet(
context: context, context: context,
isScrollControlled: true, isScrollControlled: true,
shape: shape:
const RoundedRectangleBorder( const RoundedRectangleBorder(
borderRadius: borderRadius:
BorderRadius.vertical( BorderRadius.vertical(
top: Radius top:
.circular( Radius.circular(
16)), 16)),
), ),
builder: (context) => builder: (context) =>
AssignTaskBottomSheet( AssignTaskBottomSheet(
buildingId: building.id, buildingName: building.name,
buildingName: floorName: floor.floorName,
building.name, workAreaName: area.areaName,
floorName: workLocation: area.areaName,
floor.floorName,
workAreaName:
area.areaName,
workLocation:
area.areaName,
activityName: item activityName: item
.activityMaster .activityMaster
?.name ?? ?.name ??
@ -492,12 +469,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
DateTime.now(), DateTime.now(),
), ),
); );
},
),
}),
], ],
), ),
MySpacing.height(4), MySpacing.height(6),
Row( Row(
children: [ children: [
MyText.bodySmall( MyText.bodySmall(
@ -562,80 +538,3 @@ 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,12 +50,13 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
} }
Future<void> _onTenantSelected(String tenantId) async { Future<void> _onTenantSelected(String tenantId) async {
return _controller.onTenantSelected(tenantId); await _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();
} }
@ -90,7 +91,6 @@ 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,6 +109,7 @@ 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});
@ -138,6 +139,7 @@ class _AnimatedLogo extends StatelessWidget {
} }
} }
/// Welcome Texts
class _WelcomeTexts extends StatelessWidget { class _WelcomeTexts extends StatelessWidget {
const _WelcomeTexts(); const _WelcomeTexts();
@ -164,6 +166,7 @@ class _WelcomeTexts extends StatelessWidget {
} }
} }
/// Beta Badge
class _BetaBadge extends StatelessWidget { class _BetaBadge extends StatelessWidget {
const _BetaBadge(); const _BetaBadge();
@ -185,18 +188,16 @@ class _BetaBadge extends StatelessWidget {
} }
} }
class TenantCardList extends StatelessWidget { /// Tenant Card List
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;
const TenantCardList({ 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
@ -225,16 +226,18 @@ class TenantCardList extends StatelessWidget {
(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: LocalStorage.logout, onPressed: () async {
icon: Icon(Icons.arrow_back, size: 20, color: primaryColor), await LocalStorage.logout();
},
icon:
Icon(Icons.arrow_back, size: 20, color: contentTheme.primary,),
label: MyText( label: MyText(
'Back to Login', 'Back to Login',
color: primaryColor, color: contentTheme.primary,
fontWeight: 600, fontWeight: 600,
fontSize: 14, fontSize: 14,
), ),
@ -245,16 +248,11 @@ class TenantCardList extends StatelessWidget {
} }
} }
class _TenantCard extends StatelessWidget { /// Single Tenant Card
class _TenantCard extends StatelessWidget with UIMixin {
final dynamic tenant; final dynamic tenant;
final VoidCallback onTap; final VoidCallback onTap;
final Color primaryColor; _TenantCard({required this.tenant, required this.onTap});
const _TenantCard({
required this.tenant,
required this.onTap,
required this.primaryColor,
});
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@ -299,7 +297,7 @@ class _TenantCard extends StatelessWidget {
], ],
), ),
), ),
Icon(Icons.arrow_forward_ios, size: 24, color: primaryColor), Icon(Icons.arrow_forward_ios, size: 24, color: contentTheme.primary,),
], ],
), ),
), ),
@ -308,6 +306,7 @@ class _TenantCard extends StatelessWidget {
} }
} }
/// 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});
@ -325,13 +324,14 @@ 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( return Image.network(
logoImage!, logoImage!,
fit: BoxFit.cover, fit: BoxFit.cover,
errorBuilder: (_, __, ___) => errorBuilder: (_, __, ___) => Center(
Center(child: Icon(Icons.business, color: Colors.grey.shade600)), 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.1+20 version: 1.0.0+18
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.20.2 intl: ^0.19.0
syncfusion_flutter_core: ^31.2.18 syncfusion_flutter_core: ^29.1.40
syncfusion_flutter_sliders: ^31.2.18 syncfusion_flutter_sliders: ^29.1.40
file_picker: ^10.3.2 file_picker: ^10.3.2
timelines_plus: ^1.0.4 timelines_plus: ^1.0.4
syncfusion_flutter_charts: ^31.2.18 syncfusion_flutter_charts: ^29.1.40
appflowy_board: ^0.1.2 appflowy_board: ^0.1.2
syncfusion_flutter_calendar: ^31.2.18 syncfusion_flutter_calendar: ^29.1.40
syncfusion_flutter_maps: ^31.2.18 syncfusion_flutter_maps: ^29.1.40
http: ^1.6.0 http: ^1.6.0
geolocator: ^14.0.1 geolocator: ^14.0.2
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: ^7.0.0 connectivity_plus: ^6.1.4
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: ^12.3.0 device_info_plus: ^11.3.0
flutter_local_notifications: ^19.5.0 flutter_local_notifications: 19.4.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,8 +86,6 @@ 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:
@ -98,7 +96,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: ^6.0.0 flutter_lints: ^5.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
@ -151,3 +149,6 @@ 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