Compare commits

...

56 Commits

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

View File

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

View File

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

View File

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

View File

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

View File

@ -18,7 +18,7 @@ pluginManagement {
plugins {
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
id "com.android.application" version "8.6.0" apply false
id "com.android.application" version "8.9.1" apply false
id "org.jetbrains.kotlin.android" version "2.2.21" apply false
id("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
# App info
APP_NAME="On Field Work"
APP_NAME="OnFieldWork.com"
BUILD_DIR="build/app/outputs"
echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}"

View File

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

View File

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

View File

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

View File

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

View File

@ -44,21 +44,26 @@ class AddExpenseController extends GetxController {
TextEditingController get noOfPersonsController => controllers[7];
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 ---
final isLoading = false.obs;
final isSubmitting = false.obs;
final isFetchingLocation = false.obs;
final isEditMode = 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>();
// helper setters
// helper setters
void setSelectedPaidBy(EmployeeModel? emp) {
selectedPaidBy.value = emp;
}
@ -66,7 +71,6 @@ class AddExpenseController extends GetxController {
// --- Dropdown Selections & Data ---
final selectedPaymentMode = Rxn<PaymentModeModel>();
final selectedExpenseType = Rxn<ExpenseTypeModel>();
// final selectedPaidBy = Rxn<EmployeeModel>();
final selectedProject = ''.obs;
final selectedTransactionDate = Rxn<DateTime>();
@ -93,6 +97,7 @@ class AddExpenseController extends GetxController {
employeeSearchController.addListener(
() => searchEmployees(employeeSearchController.text),
);
ever(selectedPaymentMode, (_) => _checkTransactionIdExemption());
}
@override
@ -103,6 +108,12 @@ class AddExpenseController extends GetxController {
super.onClose();
}
void _checkTransactionIdExemption() {
final selectedId = selectedPaymentMode.value?.id;
isTransactionIdExempted.value =
selectedId != null && _transactionIdExemptIds.contains(selectedId);
}
// --- Employee Search ---
Future<void> searchEmployees(String query) async {
if (query.trim().isEmpty) return employeeSearchResults.clear();
@ -171,6 +182,7 @@ class AddExpenseController extends GetxController {
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
selectedPaymentMode.value =
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
_checkTransactionIdExemption();
}
Future<void> _setPaidBy(Map<String, dynamic> data) async {
@ -536,6 +548,11 @@ class AddExpenseController extends GetxController {
if (amountController.text.trim().isEmpty) missing.add("Amount");
if (descriptionController.text.trim().isEmpty) missing.add("Description");
if (!isTransactionIdExempted.value &&
transactionIdController.text.trim().isEmpty) {
missing.add("Transaction ID");
}
if (selectedTransactionDate.value == null) {
missing.add("Transaction Date");
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {

View File

@ -4,6 +4,9 @@ import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/model/expense/expense_detail_model.dart';
import 'package:on_field_work/model/employees/employee_model.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 {
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
@ -16,6 +19,22 @@ class ExpenseDetailController extends GetxController {
bool _isInitialized = false;
final employeeSearchController = TextEditingController();
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
void init(String expenseId) {
@ -31,6 +50,36 @@ class ExpenseDetailController extends GetxController {
]);
}
/// NEW: Logic to check if the current user can submit the expense
void checkPermissionToSubmit() {
final expenseData = expense.value;
if (employeeInfo == null || expenseData == null) {
canSubmit.value = false;
return;
}
// Status ID for 'Submit' (Hardcoded ID from the original screen logic)
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
final isCreatedByCurrentUser = employeeInfo?.id == expenseData.createdBy.id;
final nextStatusIds = expenseData.nextStatus.map((e) => e.id).toList();
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
logSafe(
'🐛 Checking submit permission:\n'
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
'🐛 - Expense created by ID: ${expenseData.createdBy.id}\n'
'🐛 - Next Status IDs: $nextStatusIds\n'
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
'🐛 - Final Permission Result: $result',
level: LogLevel.debug,
);
canSubmit.value = result;
}
/// Generic method to handle API calls with loading and error states
Future<T?> _apiCallWrapper<T>(
Future<T?> Function() apiCall, String operationName) async {
@ -63,6 +112,8 @@ class ExpenseDetailController extends GetxController {
try {
expense.value = ExpenseDetailModel.fromJson(result);
logSafe("Expense details loaded successfully: ${expense.value?.id}");
// Call permission check after data is loaded
checkPermissionToSubmit();
} catch (e) {
errorMessage.value = 'Failed to parse expense details: $e';
logSafe("Parse error in fetchExpenseDetails: $e",
@ -75,8 +126,6 @@ class ExpenseDetailController extends GetxController {
}
}
// This method seems like a utility and might be better placed in a helper or utility class
// if it's used across multiple controllers. Keeping it here for now as per original code.
List<String> parsePermissionIds(dynamic permissionData) {
if (permissionData == null) return [];
if (permissionData is List) {
@ -131,8 +180,6 @@ class ExpenseDetailController extends GetxController {
allEmployees.clear();
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
@ -191,4 +238,4 @@ class ExpenseDetailController extends GetxController {
return false;
}
}
}
}

View File

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

View File

@ -1,6 +1,7 @@
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_team_list_model.dart';
class InfraProjectDetailsController extends GetxController {
final String projectId;
@ -9,25 +10,39 @@ class InfraProjectDetailsController extends GetxController {
var isLoading = true.obs;
var projectDetails = Rxn<ProjectData>();
var teamList = <ProjectAllocation>[].obs;
var teamLoading = true.obs;
var errorMessage = ''.obs;
var teamErrorMessage = ''.obs;
@override
void onInit() {
super.onInit();
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 {
try {
isLoading.value = true;
final response = await ApiService.getInfraProjectDetails(projectId: projectId);
final response =
await ApiService.getInfraProjectDetails(projectId: projectId);
if (response != null && response.success == true && response.data != null) {
if (response != null &&
response.success == true &&
response.data != null) {
projectDetails.value = response.data;
isLoading.value = false;
errorMessage.value = '';
} else {
errorMessage.value = response?.message ?? "Failed to load project details";
errorMessage.value =
response?.message ?? "Failed to load project details";
}
} catch (e) {
errorMessage.value = "Error fetching project details: $e";
@ -35,4 +50,28 @@ class InfraProjectDetailsController extends GetxController {
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:shared_preferences/shared_preferences.dart';
import 'package:on_field_work/helpers/services/app_logger.dart';
import 'package:on_field_work/helpers/services/permission_service.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/model/user_permission.dart';
import 'package:on_field_work/model/employees/employee_info.dart';
import 'package:on_field_work/model/projects_model.dart';
@ -51,7 +51,7 @@ class PermissionController extends GetxController {
Future<void> loadData(String token) async {
try {
isLoading.value = true;
final userData = await PermissionService.fetchAllUserData(token);
final userData = await AuthService.fetchAllUserData(token);
_updateState(userData);
await _storeData();
logSafe("Data loaded and state updated successfully.");

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -12,19 +12,39 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
Widget build(BuildContext context) {
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
return Obx(() {
final data = controller.purchaseInvoiceOverviewData.value;
// Show loading state while API call is in progress
if (controller.isPurchaseInvoiceLoading.value) {
return SkeletonLoaders.purchaseInvoiceDashboardSkeleton();
return Container(
decoration: cardDecoration, // Apply decoration to loading state
padding: const EdgeInsets.all(16.0),
child: SkeletonLoaders.purchaseInvoiceDashboardSkeleton(),
);
}
// Show empty state if no data
if (data == null || data.totalInvoices == 0) {
return Center(
child: MyText.bodySmall('No purchase invoices found.'),
return Container(
decoration: cardDecoration, // Apply decoration to empty state
padding: const EdgeInsets.all(16.0),
child: const _EmptyDataWidget(), // <-- Use the new empty widget
);
}
@ -42,27 +62,17 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
return _buildDashboard(metrics);
return _buildDashboard(metrics, cardDecoration);
});
}
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) {
Widget _buildDashboard(PurchaseInvoiceMetrics metrics, BoxDecoration decoration) {
const double spacing = 16.0;
const double smallSpacing = 8.0;
return Container(
padding: const EdgeInsets.all(spacing),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
boxShadow: [
BoxShadow(
color: Colors.black.withOpacity(0.08),
blurRadius: 15,
offset: const Offset(0, 5),
),
],
),
decoration: decoration, // Use the passed decoration
child: Column(
crossAxisAlignment: CrossAxisAlignment.stretch,
mainAxisSize: MainAxisSize.min,
@ -319,6 +329,56 @@ Color getColorForStatus(String status) {
/// 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 {
final String title;
@ -714,4 +774,4 @@ class _ProjectBreakdown extends StatelessWidget {
}).toList(),
);
}
}
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,7 +10,6 @@ import 'package:on_field_work/helpers/services/api_endpoints.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
import 'package:on_field_work/helpers/widgets/wave_background.dart';
class MPINAuthScreen extends StatefulWidget {
const MPINAuthScreen({super.key});
@ -91,12 +90,31 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
child: Column(
children: [
const SizedBox(height: 12),
MyText(
"Welcome to On Field Work",
fontSize: 24,
fontWeight: 800,
color: Colors.black87,
RichText(
textAlign: TextAlign.center,
text: const TextSpan(
style: TextStyle(
fontSize: 24,
fontWeight: FontWeight.w800,
color: Colors.black87,
),
children: [
TextSpan(
text: "Welcome to ",
style: TextStyle(color: Colors.black87),
),
TextSpan(
text: "OnField",
style: TextStyle(
color: Color(0xFF007BFF)), // Blue
),
TextSpan(
text: "Work",
style: TextStyle(
color: Color(0xFF71DD37)), // Green
),
],
),
),
const SizedBox(height: 10),
MyText(
@ -317,8 +335,8 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
if (isNewUser || isChangeMpin)
TextButton.icon(
onPressed: () => Get.toNamed('/dashboard'),
icon: Icon(Icons.arrow_back,
size: 18, color: contentTheme.primary),
icon:
Icon(Icons.arrow_back, size: 18, color: contentTheme.primary),
label: MyText.bodyMedium(
'Back to Home Page',
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/dashbaord/expense_breakdown_chart.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart';
import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart';
// import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart'; // Unused
// import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart'; // Unused
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.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 =
Get.put(AttendanceController());
final DynamicMenuController menuController = Get.put(DynamicMenuController());
final ProjectController projectController = Get.find<ProjectController>();
final ProjectController projectController = Get.put(ProjectController());
bool hasMpin = true;
@ -56,34 +56,35 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
// ---------------------------------------------------------------------------
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(
margin: const EdgeInsets.only(bottom: 16),
decoration: BoxDecoration(
color: Colors.white,
borderRadius: BorderRadius.circular(5),
borderRadius: cardRadius,
border: Border.all(color: Colors.black12.withOpacity(.04)),
boxShadow: [
BoxShadow(
color: Colors.black12.withOpacity(.05),
blurRadius: 12,
offset: const Offset(0, 4),
),
],
boxShadow: cardShadow,
),
child: child,
);
}
Widget _sectionTitle(String title) {
return Padding(
padding: const EdgeInsets.only(left: 4, bottom: 8),
child: Text(
child: MyText.titleMedium(
title,
style: const TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
fontWeight: 700,
color: Colors.black87,
),
);
}
@ -120,9 +121,33 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
end: Alignment.bottomRight,
),
),
child: const Text(
'No attendance data available',
style: TextStyle(color: Colors.white),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
Row(
children: [
const Icon(Icons.info_outline,
size: 30, color: Colors.white),
MySpacing.width(10),
const Expanded(
child: Text(
"No attendance data available yet.",
style: TextStyle(
color: Colors.white,
fontSize: 13,
fontWeight: FontWeight.w500,
),
),
),
],
),
MySpacing.height(12),
const Text(
"You are not added to this project or attendance data is not available.",
style: TextStyle(color: Colors.white70, fontSize: 13),
),
],
),
);
}
@ -137,6 +162,12 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
? 'Checked In'
: 'Checked 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(
padding: const EdgeInsets.all(16),
decoration: BoxDecoration(
@ -185,19 +216,15 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
),
],
),
const SizedBox(height: 12),
MySpacing.height(12),
Text(
!isCheckedIn
? 'You are not checked-in yet. Please check-in to start your work.'
: !isCheckedOut
? 'You are currently checked-in. Don\'t forget to check-out after your work.'
: 'You have checked-out for today.',
infoText,
style: const TextStyle(
color: Colors.white70,
fontSize: 13,
),
),
const SizedBox(height: 12),
MySpacing.height(12),
Row(
mainAxisAlignment: MainAxisAlignment.end,
children: [
@ -236,8 +263,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final bool projectSelected = projectController.selectedProject != null;
// these are String constants from permission_constants.dart
final List<String> cardOrder = [
const List<String> cardOrder = [
MenuItems.attendance,
MenuItems.employees,
MenuItems.directory,
@ -280,14 +306,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'Modules',
style: TextStyle(
fontSize: 16,
fontWeight: FontWeight.w700,
color: Colors.black87,
),
),
_sectionTitle('Modules'),
if (!projectSelected)
Container(
padding: const EdgeInsets.symmetric(
@ -312,7 +331,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
),
GridView.builder(
shrinkWrap: true,
physics: const NeverScrollableScrollPhysics(),
physics: const NeverScrollableScrollPhysics(), // Important!
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
crossAxisCount: 3,
@ -326,8 +345,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final item = allowed[id]!;
final _DashboardCardMeta cardMeta = meta[id]!;
// Attendance is the only module not requiring a project
final bool isEnabled =
item.name == 'Attendance' ? true : projectSelected;
item.id == MenuItems.attendance ? true : projectSelected;
return GestureDetector(
onTap: () {
@ -371,7 +391,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
color:
isEnabled ? cardMeta.color : Colors.grey.shade300,
),
const SizedBox(height: 6),
MySpacing.height(6),
Padding(
padding: const EdgeInsets.symmetric(horizontal: 2),
child: Text(
@ -413,10 +433,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
final String? selectedId = projectController.selectedProjectId.value;
if (isLoading) {
return SkeletonLoaders.dashboardCardsSkeleton(
maxWidth: MediaQuery.of(context).size.width,
);
return SkeletonLoaders.projectSelectorSkeleton();
}
final String selectedProjectName = projects
.firstWhereOrNull(
(p) => p.id == selectedId,
)
?.name ??
'Select Project';
return Column(
crossAxisAlignment: CrossAxisAlignment.start,
@ -445,15 +469,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
color: Colors.blue,
size: 20,
),
const SizedBox(width: 12),
MySpacing.width(12),
Expanded(
child: Text(
projects
.firstWhereOrNull(
(p) => p.id == selectedId,
)
?.name ??
'Select Project',
selectedProjectName,
style: const TextStyle(
fontSize: 15,
fontWeight: FontWeight.w600,
@ -498,17 +517,17 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
),
child: Column(
children: [
TextField(
const TextField(
decoration: InputDecoration(
hintText: 'Search project...',
isDense: true,
prefixIcon: const Icon(Icons.search),
prefixIcon: Icon(Icons.search),
border: OutlineInputBorder(
borderRadius: BorderRadius.circular(5),
borderRadius: BorderRadius.all(Radius.circular(5)),
),
),
),
const SizedBox(height: 10),
MySpacing.height(10),
Expanded(
child: ListView.builder(
itemCount: projects.length,
@ -534,51 +553,62 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
),
);
}
// ---------------------------------------------------------------------------
// Build
// Build (MODIFIED FOR FIXED HEADER)
// ---------------------------------------------------------------------------
@override
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xfff5f6fa),
body: Layout(
child: SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_projectSelector(),
MySpacing.height(20),
_quickActions(),
MySpacing.height(20),
_dashboardModules(),
MySpacing.height(20),
_sectionTitle('Reports & Analytics'),
CompactPurchaseInvoiceDashboard(),
MySpacing.height(20),
CollectionsHealthWidget(),
MySpacing.height(20),
_cardWrapper(
child: ExpenseTypeReportChart(),
),
_cardWrapper(
child: ExpenseByStatusWidget(
controller: dashboardController,
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: const Color(0xfff5f6fa),
body: Layout(
child: Stack(
children: [
// Main content
Padding(
padding:
const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_projectSelector(),
MySpacing.height(20),
Expanded(
child: SingleChildScrollView(
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
_quickActions(),
MySpacing.height(20),
_dashboardModules(),
MySpacing.height(20),
_sectionTitle('Reports & Analytics'),
_cardWrapper(
child: ExpenseTypeReportChart(),
),
_cardWrapper(
child: ExpenseByStatusWidget(
controller: dashboardController,
),
),
_cardWrapper(
child: MonthlyExpenseDashboardChart(),
),
MySpacing.height(80), // give space under content
],
),
),
),
),
_cardWrapper(
child: MonthlyExpenseDashboardChart(),
),
MySpacing.height(20),
],
],
),
),
),
],
),
);
}
),
);
}
}
class _DashboardCardMeta {
final IconData icon;
final Color color;

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -10,11 +10,16 @@ import 'package:on_field_work/helpers/utils/launcher_utils.dart';
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
import 'package:on_field_work/helpers/widgets/my_text.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/infra_project/infra_project_screen_details_controller.dart';
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
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 {
final String projectId;
@ -36,51 +41,213 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
late final TabController _tabController;
final DynamicMenuController menuController =
Get.find<DynamicMenuController>();
late final InfraProjectDetailsController controller;
final List<_InfraTab> _tabs = [];
@override
void initState() {
super.initState();
controller =
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
_prepareTabs();
}
void _prepareTabs() {
// Profile tab is always added
_tabs.add(_InfraTab(name: "Profile", view: _buildProfileTab()));
_tabs.add(_InfraTab(
name: "Profile",
icon: Icons.person,
view: _buildProfileTab(),
));
_tabs.add(_InfraTab(
name: "Team",
icon: Icons.group,
view: _buildTeamTab(),
));
final allowedMenu = menuController.menuItems.where((m) => m.available);
if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) {
_tabs.add(
_InfraTab(
name: "Task Planning",
view: DailyTaskPlanningScreen(projectId: widget.projectId),
),
);
_tabs.add(_InfraTab(
name: "Task Planning",
icon: Icons.task,
view: DailyTaskPlanningScreen(projectId: widget.projectId),
));
}
if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) {
_tabs.add(
_InfraTab(
name: "Task Progress",
view: DailyProgressReportScreen(projectId: widget.projectId),
),
);
_tabs.add(_InfraTab(
name: "Task Progress",
icon: Icons.trending_up,
view: DailyProgressReportScreen(projectId: widget.projectId),
));
}
_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
void dispose() {
_tabController.dispose();
super.dispose();
}
Widget _buildProfileTab() {
final controller =
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
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() {
return Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
@ -153,35 +320,40 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
titleIcon: Icons.info_outline,
children: [
_buildDetailRow(
icon: Icons.location_on_outlined,
label: 'Address',
value: data.projectAddress ?? "-"),
icon: Icons.location_on_outlined,
label: 'Address',
value: data.projectAddress ?? "-",
),
_buildDetailRow(
icon: Icons.calendar_today_outlined,
label: 'Start Date',
value: data.startDate != null
? DateFormat('d/M/yyyy').format(data.startDate!)
: "-"),
icon: Icons.calendar_today_outlined,
label: 'Start Date',
value: data.startDate != null
? DateFormat('d/M/yyyy').format(data.startDate!)
: "-",
),
_buildDetailRow(
icon: Icons.calendar_today_outlined,
label: 'End Date',
value: data.endDate != null
? DateFormat('d/M/yyyy').format(data.endDate!)
: "-"),
icon: Icons.calendar_today_outlined,
label: 'End Date',
value: data.endDate != null
? DateFormat('d/M/yyyy').format(data.endDate!)
: "-",
),
_buildDetailRow(
icon: Icons.flag_outlined,
label: 'Status',
value: data.projectStatus?.status ?? "-"),
icon: Icons.flag_outlined,
label: 'Status',
value: data.projectStatus?.status ?? "-",
),
_buildDetailRow(
icon: Icons.person_outline,
label: 'Contact Person',
value: data.contactPerson ?? "-",
isActionable: true,
onTap: () {
if (data.contactPerson != null) {
LauncherUtils.launchPhone(data.contactPerson!);
}
}),
icon: Icons.person_outline,
label: 'Contact Person',
value: data.contactPerson ?? "-",
isActionable: true,
onTap: () {
if (data.contactPerson != null) {
LauncherUtils.launchPhone(data.contactPerson!);
}
},
),
],
);
}
@ -192,22 +364,24 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
titleIcon: Icons.business_outlined,
children: [
_buildDetailRow(
icon: Icons.person_outline,
label: 'Name',
value: promoter.name ?? "-"),
icon: Icons.person_outline,
label: 'Name',
value: promoter.name ?? "-",
),
_buildDetailRow(
icon: Icons.phone_outlined,
label: 'Contact',
value: promoter.contactNumber ?? "-",
isActionable: true,
onTap: () =>
LauncherUtils.launchPhone(promoter.contactNumber ?? "")),
icon: Icons.phone_outlined,
label: 'Contact',
value: promoter.contactNumber ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchPhone(promoter.contactNumber ?? ""),
),
_buildDetailRow(
icon: Icons.email_outlined,
label: 'Email',
value: promoter.email ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchEmail(promoter.email ?? "")),
icon: Icons.email_outlined,
label: 'Email',
value: promoter.email ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchEmail(promoter.email ?? ""),
),
],
);
}
@ -218,19 +392,24 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
titleIcon: Icons.engineering_outlined,
children: [
_buildDetailRow(
icon: Icons.person_outline, label: 'Name', value: pmc.name ?? "-"),
icon: Icons.person_outline,
label: 'Name',
value: pmc.name ?? "-",
),
_buildDetailRow(
icon: Icons.phone_outlined,
label: 'Contact',
value: pmc.contactNumber ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? "")),
icon: Icons.phone_outlined,
label: 'Contact',
value: pmc.contactNumber ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? ""),
),
_buildDetailRow(
icon: Icons.email_outlined,
label: 'Email',
value: pmc.email ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchEmail(pmc.email ?? "")),
icon: Icons.email_outlined,
label: 'Email',
value: pmc.email ?? "-",
isActionable: true,
onTap: () => LauncherUtils.launchEmail(pmc.email ?? ""),
),
],
);
}
@ -251,7 +430,9 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
crossAxisAlignment: CrossAxisAlignment.start,
children: [
Container(
padding: const EdgeInsets.all(8), child: Icon(icon, size: 20)),
padding: const EdgeInsets.all(8),
child: Icon(icon, size: 20),
),
MySpacing.width(16),
Expanded(
child: Column(
@ -329,6 +510,19 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
projectName: widget.projectName,
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(
children: [
Container(
@ -349,6 +543,7 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
PillTabBar(
controller: _tabController,
tabs: _tabs.map((e) => e.name).toList(),
icons: _tabs.map((e) => e.icon).toList(),
selectedColor: contentTheme.primary,
unselectedColor: Colors.grey.shade600,
indicatorColor: contentTheme.primary,
@ -371,7 +566,12 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
/// INTERNAL MODEL
class _InfraTab {
final String name;
final IconData icon;
final Widget view;
_InfraTab({required this.name, required this.view});
_InfraTab({
required this.name,
required this.icon,
required this.view,
});
}

View File

@ -11,6 +11,7 @@ import 'package:on_field_work/model/infra_project/infra_project_list.dart';
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart';
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
class InfraProjectScreen extends StatefulWidget {
const InfraProjectScreen({super.key});
@ -245,7 +246,7 @@ class _InfraProjectScreenState extends State<InfraProjectScreen> with UIMixin {
Expanded(
child: Obx(() {
if (controller.isLoading.value) {
return const Center(child: CircularProgressIndicator());
return Center(child: SkeletonLoaders.serviceProjectListSkeletonLoader());
}
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/images.dart';
import 'package:on_field_work/view/layouts/user_profile_right_bar.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
class Layout extends StatefulWidget {
@ -24,7 +24,6 @@ class Layout extends StatefulWidget {
class _LayoutState extends State<Layout> with UIMixin {
final LayoutController controller = LayoutController();
final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo();
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
bool hasMpin = true;
@ -46,72 +45,69 @@ class _LayoutState extends State<Layout> with UIMixin {
@override
Widget build(BuildContext context) {
return MyResponsive(builder: (context, _, screenMT) {
return GetBuilder(
return GetBuilder<LayoutController>(
init: controller,
builder: (_) {
return (screenMT.isMobile || screenMT.isTablet)
? _buildScaffold(context, isMobile: true)
: _buildScaffold(context);
return _buildScaffold(context);
},
);
});
}
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
Widget _buildScaffold(BuildContext context) {
final primaryColor = contentTheme.primary;
return Scaffold(
key: controller.scaffoldKey,
endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton,
body: Column(
children: [
// Solid primary background area
Container(
key: controller.scaffoldKey,
endDrawer: const UserProfileBar(),
floatingActionButton: widget.floatingActionButton,
body: Column(
children: [
Container(
width: double.infinity,
color: primaryColor,
child: _buildHeaderContent(),
),
Expanded(
child: Container(
width: double.infinity,
color: primaryColor,
child: _buildHeaderContent(isMobile),
),
Expanded(
child: Container(
width: double.infinity,
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
primaryColor,
primaryColor.withOpacity(0.7),
primaryColor.withOpacity(0.0),
],
stops: const [0.0, 0.1, 0.3],
),
decoration: BoxDecoration(
gradient: LinearGradient(
begin: Alignment.topCenter,
end: Alignment.bottomCenter,
colors: [
primaryColor,
primaryColor.withOpacity(0.7),
primaryColor.withOpacity(0.0),
],
stops: const [0.0, 0.1, 0.3],
),
child: SafeArea(
top: false,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () {},
child: SingleChildScrollView(
key: controller.scrollKey,
padding: EdgeInsets.zero,
child: widget.child,
),
),
),
child: SafeArea(
top: false,
child: GestureDetector(
behavior: HitTestBehavior.translucent,
onTap: () =>
FocusScope.of(context).unfocus(),
child: widget.child ??
const SizedBox.shrink(),
),
),
),
],
));
),
],
),
);
}
Widget _buildHeaderContent(bool isMobile) {
final selectedTenant = TenantService.currentTenant;
Widget _buildHeaderContent() {
final selectedTenant = AuthService.currentTenant;
final bool isBeta = ApiEndpoints.baseUrl.contains("stage");
return Padding(
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
child: Container(
margin: const EdgeInsets.only(bottom: 18),
margin: const EdgeInsets.only(bottom: 10),
width: double.infinity,
padding: const EdgeInsets.all(12),
decoration: BoxDecoration(
@ -139,7 +135,7 @@ class _LayoutState extends State<Layout> with UIMixin {
),
// Beta badge
if (ApiEndpoints.baseUrl.contains("stage"))
if (isBeta)
Positioned(
bottom: 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/controller/auth/mpin_controller.dart';
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
import 'package:on_field_work/helpers/services/tenant_service.dart';
import 'package:on_field_work/helpers/services/auth_service.dart';
import 'package:on_field_work/view/tenant/tenant_selection_screen.dart';
import 'package:on_field_work/controller/tenant/tenant_switch_controller.dart';
import 'package:on_field_work/helpers/theme/theme_editor_widget.dart';
@ -285,7 +285,7 @@ class _UserProfileBarState extends State<UserProfileBar>
);
}
final selectedTenant = TenantService.currentTenant;
final selectedTenant = AuthService.currentTenant;
final sortedTenants = List.of(tenants);
if (selectedTenant != null) {

View File

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

View File

@ -3,8 +3,9 @@ import 'package:get/get.dart';
import 'package:flutter_localizations/flutter_localizations.dart';
import 'package: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/extensions/app_localization_delegate.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/storage/local_storage.dart';
@ -13,84 +14,120 @@ import 'package:on_field_work/helpers/theme/theme_customizer.dart';
import 'package:on_field_work/helpers/theme/app_notifier.dart';
import 'package:on_field_work/routes.dart';
class MyApp extends StatelessWidget {
final bool isOffline;
import 'package:on_field_work/view/mandatory_update_screen.dart';
class MyApp extends StatefulWidget {
final bool 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 {
try {
final token = LocalStorage.getJwtToken();
if (token == null || token.isEmpty) {
logSafe("User not logged in. Routing to /auth/login-option");
return "/auth/login-option";
}
final bool hasMpin = LocalStorage.getIsMpin();
if (hasMpin) {
if (LocalStorage.getIsMpin()) {
await LocalStorage.setBool("mpin_verified", false);
logSafe("Routing to /auth/mpin-auth");
return "/auth/mpin-auth";
}
logSafe("No MPIN. Routing to /dashboard");
return "/dashboard";
} catch (e, stacktrace) {
logSafe("Error determining initial route",
level: LogLevel.error, error: e, stackTrace: stacktrace);
} catch (e, stack) {
logSafe(
"Initial route ERROR",
error: e,
stackTrace: stack,
level: LogLevel.error,
);
return "/auth/login-option";
}
}
// REVISED: Helper Widget to show a full-screen, well-designed offline status
Widget _buildConnectivityOverlay(BuildContext context) {
// If not offline, return an empty widget.
if (!isOffline) return const SizedBox.shrink();
// Otherwise, return a full-screen overlay.
/// -------------------------
/// Offline Overlay (Blocking)
/// -------------------------
Widget _buildOfflineOverlay() {
return Directionality(
textDirection: AppTheme.textDirection,
child: Scaffold(
backgroundColor:
Colors.grey.shade100, // Light background for the offline state
backgroundColor: Colors.grey.shade200,
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.cloud_off,
color: Colors.red.shade700, // Prominent color
size: 100,
),
const SizedBox(height: 24),
Icon(Icons.cloud_off, size: 100, color: Colors.red.shade600),
const SizedBox(height: 20),
const Text(
"You Are Offline",
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.bold,
color: Colors.black87,
),
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
),
const SizedBox(height: 8),
const SizedBox(height: 10),
const Padding(
padding: EdgeInsets.symmetric(horizontal: 40.0),
padding: EdgeInsets.symmetric(horizontal: 32),
child: Text(
"Please check your internet connection and try again.",
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"),
// ),
],
),
),
@ -98,21 +135,28 @@ class MyApp extends StatelessWidget {
);
}
/// -------------------------
/// Build
/// -------------------------
@override
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>(
builder: (_, notifier, __) {
return FutureBuilder<String>(
future: _getInitialRoute(),
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) {
return const MaterialApp(
home: Center(child: CircularProgressIndicator()),
@ -127,29 +171,19 @@ class MyApp extends StatelessWidget {
navigatorKey: NavigationService.navigatorKey,
initialRoute: snapshot.data!,
getPages: getPageRoute(),
builder: (context, child) {
NavigationService.registerContext(context);
// 💡 REVISED: Use a Stack to place the offline overlay ON TOP of the app content.
// This allows the full-screen view to cover everything, including the main app content.
return Stack(
children: [
Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
),
// 2. The full-screen connectivity overlay, only visible when offline
_buildConnectivityOverlay(context),
],
);
},
localizationsDelegates: [
AppLocalizationsDelegate(context),
supportedLocales: Language.getLocales(),
localizationsDelegates: const [
GlobalMaterialLocalizations.delegate,
GlobalWidgetsLocalizations.delegate,
GlobalCupertinoLocalizations.delegate,
],
supportedLocales: Language.getLocales(),
builder: (context, child) {
NavigationService.registerContext(context);
return Directionality(
textDirection: AppTheme.textDirection,
child: child ?? const SizedBox(),
);
},
);
},
);

View File

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

View File

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

View File

@ -29,6 +29,9 @@ class _SplashScreenState extends State<SplashScreen>
// Animation for logo and text fade-in
late Animation<double> _opacityAnimation;
// Animation for the gradient shimmer effect (moves from -1.0 to 2.0)
late Animation<double> _shimmerAnimation;
@override
void initState() {
super.initState();
@ -39,7 +42,7 @@ class _SplashScreenState extends State<SplashScreen>
vsync: this,
);
// Initial scale-in: from 0.0 to 1.0 (happens in the first 40% of the duration)
// Initial scale-in: from 0.5 to 1.0 (happens in the first 40% of the duration)
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
CurvedAnimation(
parent: _controller,
@ -56,7 +59,15 @@ class _SplashScreenState extends State<SplashScreen>
),
);
// Floating effect: from 0.0 to 1.0 (loops repeatedly after initial animations)
// Shimmer/Gradient Animation: Moves the gradient horizontally from left to right
_shimmerAnimation = Tween<double>(begin: -1.0, end: 2.0).animate(
CurvedAnimation(
parent: _controller,
curve: const Interval(0.0, 1.0, curve: Curves.linear),
),
);
// Floating effect: from -8.0 to 8.0 (loops repeatedly after initial animations)
_floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate(
CurvedAnimation(
parent: _controller,
@ -66,10 +77,10 @@ class _SplashScreenState extends State<SplashScreen>
// Start the complex animation sequence
_controller.forward().then((_) {
// After the initial scale/fade, switch to repeating the float animation
// After the initial scale/fade, switch to repeating the float and shimmer animation
if (mounted) {
_controller.repeat(
min: 0.4, // Start repeat from the float interval
min: 0.4, // Keep repeat range for float animation
max: 1.0,
reverse: true,
);
@ -83,6 +94,70 @@ class _SplashScreenState extends State<SplashScreen>
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
Widget _buildProgressIndicator() {
return SizedBox(
@ -98,7 +173,6 @@ class _SplashScreenState extends State<SplashScreen>
Widget build(BuildContext context) {
return Scaffold(
backgroundColor: widget.backgroundColor,
// Full screen display, no SafeArea needed for a full bleed splash
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
@ -127,6 +201,15 @@ class _SplashScreenState extends State<SplashScreen>
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)
if (widget.message != null)
FadeTransition(

View File

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

View File

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

View File

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