Compare commits
56 Commits
main
...
Manish_Dev
| Author | SHA1 | Date | |
|---|---|---|---|
| f0a7d97ec8 | |||
| a69246b11e | |||
| f305f7ff41 | |||
| 73bd5cdc92 | |||
| 48a4eb2ca7 | |||
| 2e1b3065df | |||
| 42c34ad26a | |||
| 6b1a64f3b1 | |||
| 74377288eb | |||
| 415f83f877 | |||
| 4d012b78f7 | |||
| 8155468cd4 | |||
| 874c2cff4d | |||
| a95e0be48b | |||
| c9f4795de6 | |||
| a6743cfd9b | |||
| b96ed51da2 | |||
| 7da7fadbc9 | |||
| fe5cae9889 | |||
| 55f36fac6d | |||
| 81de795e93 | |||
| 4e577bd7eb | |||
| 0ac8998c59 | |||
| c45cb6158e | |||
| b2e3398bb1 | |||
| 2aa6d13a71 | |||
| e2fc81ba0e | |||
| 57634d7bd2 | |||
| 03082aeea9 | |||
| f6f0cd6790 | |||
| 5f1693869d | |||
| d4c7eae981 | |||
| 9389e081c9 | |||
| 6cd9fbe57b | |||
| 0e79aa4793 | |||
| f0f1ff1a5c | |||
| 0fa5a85d79 | |||
| 5c6c6289cd | |||
| fc099cccb5 | |||
| 22b61b7024 | |||
| e8fd420d51 | |||
| 1279b0e00f | |||
| 03a3c1e06c | |||
| 1bf676f64a | |||
| fbfc54159c | |||
| 7ce0a8555a | |||
| 3603b12f9c | |||
| b907e76c12 | |||
| 406ab30dba | |||
| 18cb0068e6 | |||
| 307b3ceb96 | |||
| c96aa42e81 | |||
| b2205c18f4 | |||
| f937bd849f | |||
| 28fbc2ad29 | |||
| b1741bbb0c |
@ -1,4 +1,4 @@
|
|||||||
# On Field Work
|
# OnFieldWork.com
|
||||||
|
|
||||||
A new Flutter project.
|
A new Flutter project.
|
||||||
|
|
||||||
|
|||||||
@ -39,7 +39,7 @@ android {
|
|||||||
// Specify your unique Application ID. This identifies your app on Google Play.
|
// Specify your unique Application ID. This identifies your app on Google Play.
|
||||||
applicationId = "com.marcoonfieldwork.aiot"
|
applicationId = "com.marcoonfieldwork.aiot"
|
||||||
// Set minimum and target SDK versions based on Flutter's configuration
|
// Set minimum and target SDK versions based on Flutter's configuration
|
||||||
minSdk = 23
|
minSdkVersion = flutter.minSdkVersion
|
||||||
targetSdk = flutter.targetSdkVersion
|
targetSdk = flutter.targetSdkVersion
|
||||||
// Set version code and name based on Flutter's configuration (from pubspec.yaml)
|
// Set version code and name based on Flutter's configuration (from pubspec.yaml)
|
||||||
versionCode = flutter.versionCode
|
versionCode = flutter.versionCode
|
||||||
|
|||||||
@ -8,7 +8,7 @@
|
|||||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS"/>
|
||||||
|
|
||||||
<application
|
<application
|
||||||
android:label="On Field Work"
|
android:label="OnFieldWork.com"
|
||||||
android:name="${applicationName}"
|
android:name="${applicationName}"
|
||||||
android:icon="@mipmap/ic_launcher">
|
android:icon="@mipmap/ic_launcher">
|
||||||
<activity
|
<activity
|
||||||
|
|||||||
@ -2,4 +2,4 @@ distributionBase=GRADLE_USER_HOME
|
|||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-all.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.13-all.zip
|
||||||
@ -18,7 +18,7 @@ pluginManagement {
|
|||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
id "dev.flutter.flutter-plugin-loader" version "1.0.0"
|
||||||
id "com.android.application" version "8.6.0" apply false
|
id "com.android.application" version "8.9.1" apply false
|
||||||
id "org.jetbrains.kotlin.android" version "2.2.21" apply false
|
id "org.jetbrains.kotlin.android" version "2.2.21" apply false
|
||||||
id("com.google.gms.google-services") version "4.4.2" apply false
|
id("com.google.gms.google-services") version "4.4.2" apply false
|
||||||
}
|
}
|
||||||
|
|||||||
@ -14,7 +14,7 @@ YELLOW='\033[1;33m'
|
|||||||
NC='\033[0m' # No Color
|
NC='\033[0m' # No Color
|
||||||
|
|
||||||
# App info
|
# App info
|
||||||
APP_NAME="On Field Work"
|
APP_NAME="OnFieldWork.com"
|
||||||
BUILD_DIR="build/app/outputs"
|
BUILD_DIR="build/app/outputs"
|
||||||
|
|
||||||
echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}"
|
echo -e "${CYAN}🚀 Starting Flutter build script for $APP_NAME...${NC}"
|
||||||
|
|||||||
@ -5,7 +5,7 @@
|
|||||||
<key>CFBundleDevelopmentRegion</key>
|
<key>CFBundleDevelopmentRegion</key>
|
||||||
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
<string>$(DEVELOPMENT_LANGUAGE)</string>
|
||||||
<key>CFBundleDisplayName</key>
|
<key>CFBundleDisplayName</key>
|
||||||
<string>On Field Work</string>
|
<string>OnFieldWork.com</string>
|
||||||
<key>CFBundleExecutable</key>
|
<key>CFBundleExecutable</key>
|
||||||
<string>$(EXECUTABLE_NAME)</string>
|
<string>$(EXECUTABLE_NAME)</string>
|
||||||
<key>CFBundleIdentifier</key>
|
<key>CFBundleIdentifier</key>
|
||||||
|
|||||||
@ -8,5 +8,5 @@ class AppConstant {
|
|||||||
static int iOSAppVersion = 1;
|
static int iOSAppVersion = 1;
|
||||||
static String version = "1.0.0";
|
static String version = "1.0.0";
|
||||||
|
|
||||||
static String get appName => 'On Field Work';
|
static String get appName => 'OnFieldWork.com';
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,26 +12,21 @@ import 'package:on_field_work/model/dashboard/collection_overview_model.dart';
|
|||||||
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
|
import 'package:on_field_work/model/dashboard/purchase_invoice_model.dart';
|
||||||
|
|
||||||
class DashboardController extends GetxController {
|
class DashboardController extends GetxController {
|
||||||
// Dependencies
|
|
||||||
final ProjectController projectController = Get.put(ProjectController());
|
final ProjectController projectController = Get.put(ProjectController());
|
||||||
|
|
||||||
// =========================
|
// --------------------------
|
||||||
// 1. STATE VARIABLES
|
// STATE VARIABLES
|
||||||
// =========================
|
// --------------------------
|
||||||
|
|
||||||
// Attendance
|
|
||||||
final roleWiseData = <Map<String, dynamic>>[].obs;
|
final roleWiseData = <Map<String, dynamic>>[].obs;
|
||||||
final attendanceSelectedRange = '15D'.obs;
|
final attendanceSelectedRange = '15D'.obs;
|
||||||
final attendanceIsChartView = true.obs;
|
final attendanceIsChartView = true.obs;
|
||||||
final isAttendanceLoading = false.obs;
|
final isAttendanceLoading = false.obs;
|
||||||
|
|
||||||
// Project Progress
|
|
||||||
final projectChartData = <ChartTaskData>[].obs;
|
final projectChartData = <ChartTaskData>[].obs;
|
||||||
final projectSelectedRange = '15D'.obs;
|
final projectSelectedRange = '15D'.obs;
|
||||||
final projectIsChartView = true.obs;
|
final projectIsChartView = true.obs;
|
||||||
final isProjectLoading = false.obs;
|
final isProjectLoading = false.obs;
|
||||||
|
|
||||||
// Overview Counts
|
|
||||||
final totalProjects = 0.obs;
|
final totalProjects = 0.obs;
|
||||||
final ongoingProjects = 0.obs;
|
final ongoingProjects = 0.obs;
|
||||||
final isProjectsLoading = false.obs;
|
final isProjectsLoading = false.obs;
|
||||||
@ -44,12 +39,12 @@ class DashboardController extends GetxController {
|
|||||||
final inToday = 0.obs;
|
final inToday = 0.obs;
|
||||||
final isTeamsLoading = false.obs;
|
final isTeamsLoading = false.obs;
|
||||||
|
|
||||||
// Expenses & Reports
|
|
||||||
final isPendingExpensesLoading = false.obs;
|
final isPendingExpensesLoading = false.obs;
|
||||||
final pendingExpensesData = Rx<PendingExpensesData?>(null);
|
final pendingExpensesData = Rx<PendingExpensesData?>(null);
|
||||||
|
|
||||||
final isExpenseTypeReportLoading = false.obs;
|
final isExpenseTypeReportLoading = false.obs;
|
||||||
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
|
final expenseTypeReportData = Rx<ExpenseTypeReportData?>(null);
|
||||||
|
|
||||||
final expenseReportStartDate =
|
final expenseReportStartDate =
|
||||||
DateTime.now().subtract(const Duration(days: 15)).obs;
|
DateTime.now().subtract(const Duration(days: 15)).obs;
|
||||||
final expenseReportEndDate = DateTime.now().obs;
|
final expenseReportEndDate = DateTime.now().obs;
|
||||||
@ -63,21 +58,17 @@ class DashboardController extends GetxController {
|
|||||||
final expenseTypes = <ExpenseTypeModel>[].obs;
|
final expenseTypes = <ExpenseTypeModel>[].obs;
|
||||||
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
final selectedExpenseType = Rx<ExpenseTypeModel?>(null);
|
||||||
|
|
||||||
// Teams/Employees
|
|
||||||
final isLoadingEmployees = true.obs;
|
final isLoadingEmployees = true.obs;
|
||||||
final employees = <EmployeeModel>[].obs;
|
final employees = <EmployeeModel>[].obs;
|
||||||
final uploadingStates = <String, RxBool>{}.obs;
|
final uploadingStates = <String, RxBool>{}.obs;
|
||||||
|
|
||||||
// Collection
|
|
||||||
final isCollectionOverviewLoading = true.obs;
|
final isCollectionOverviewLoading = true.obs;
|
||||||
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
|
final collectionOverviewData = Rx<CollectionOverviewData?>(null);
|
||||||
// =========================
|
|
||||||
// Purchase Invoice Overview
|
|
||||||
// =========================
|
|
||||||
final isPurchaseInvoiceLoading = true.obs;
|
final isPurchaseInvoiceLoading = true.obs;
|
||||||
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
|
final purchaseInvoiceOverviewData = Rx<PurchaseInvoiceOverviewData?>(null);
|
||||||
// Constants
|
|
||||||
final List<String> ranges = ['7D', '15D', '30D'];
|
final List<String> ranges = const ['7D', '15D', '30D'];
|
||||||
static const _rangeDaysMap = {
|
static const _rangeDaysMap = {
|
||||||
'7D': 7,
|
'7D': 7,
|
||||||
'15D': 15,
|
'15D': 15,
|
||||||
@ -86,19 +77,22 @@ class DashboardController extends GetxController {
|
|||||||
'6M': 180
|
'6M': 180
|
||||||
};
|
};
|
||||||
|
|
||||||
// =========================
|
|
||||||
// 2. COMPUTED PROPERTIES
|
|
||||||
// =========================
|
|
||||||
|
|
||||||
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
|
|
||||||
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
|
|
||||||
|
|
||||||
// DSO Calculation Constants
|
|
||||||
static const double _w0_30 = 15.0;
|
static const double _w0_30 = 15.0;
|
||||||
static const double _w30_60 = 45.0;
|
static const double _w30_60 = 45.0;
|
||||||
static const double _w60_90 = 75.0;
|
static const double _w60_90 = 75.0;
|
||||||
static const double _w90_plus = 105.0;
|
static const double _w90_plus = 105.0;
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// LATEST PROJECT ID (for race condition fix)
|
||||||
|
// --------------------------
|
||||||
|
String _latestProjectId = '';
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// COMPUTED PROPERTIES
|
||||||
|
// --------------------------
|
||||||
|
int getAttendanceDays() => _rangeDaysMap[attendanceSelectedRange.value] ?? 7;
|
||||||
|
int getProjectDays() => _rangeDaysMap[projectSelectedRange.value] ?? 7;
|
||||||
|
|
||||||
double get calculatedDSO {
|
double get calculatedDSO {
|
||||||
final data = collectionOverviewData.value;
|
final data = collectionOverviewData.value;
|
||||||
if (data == null || data.totalDueAmount == 0) return 0.0;
|
if (data == null || data.totalDueAmount == 0) return 0.0;
|
||||||
@ -111,44 +105,46 @@ class DashboardController extends GetxController {
|
|||||||
return weightedDue / data.totalDueAmount;
|
return weightedDue / data.totalDueAmount;
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
// --------------------------
|
||||||
// 3. LIFECYCLE
|
// LIFECYCLE
|
||||||
// =========================
|
// --------------------------
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
logSafe('DashboardController initialized', level: LogLevel.info);
|
logSafe('DashboardController initialized', level: LogLevel.info);
|
||||||
|
|
||||||
// Project Selection Listener
|
// --------------------------
|
||||||
|
// Project change listener
|
||||||
|
// --------------------------
|
||||||
ever<String>(projectController.selectedProjectId, (id) {
|
ever<String>(projectController.selectedProjectId, (id) {
|
||||||
if (id.isNotEmpty) {
|
if (id.isNotEmpty) {
|
||||||
fetchAllDashboardData();
|
_latestProjectId = id; // track latest project
|
||||||
fetchTodaysAttendance(id);
|
fetchAllDashboardData(id);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Expense Report Date Listener
|
// Expense Report Date Listener
|
||||||
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
everAll([expenseReportStartDate, expenseReportEndDate], (_) {
|
||||||
if (projectController.selectedProjectId.value.isNotEmpty) {
|
final id = projectController.selectedProjectId.value;
|
||||||
|
if (id.isNotEmpty) {
|
||||||
fetchExpenseTypeReport(
|
fetchExpenseTypeReport(
|
||||||
startDate: expenseReportStartDate.value,
|
startDate: expenseReportStartDate.value,
|
||||||
endDate: expenseReportEndDate.value,
|
endDate: expenseReportEndDate.value,
|
||||||
|
projectId: id,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Chart Range Listeners
|
|
||||||
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
ever(attendanceSelectedRange, (_) => fetchRoleWiseAttendance());
|
||||||
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
ever(projectSelectedRange, (_) => fetchProjectProgress());
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
// --------------------------
|
||||||
// 4. USER ACTIONS
|
// USER ACTIONS
|
||||||
// =========================
|
// --------------------------
|
||||||
|
|
||||||
void updateAttendanceRange(String range) =>
|
void updateAttendanceRange(String range) =>
|
||||||
attendanceSelectedRange.value = range;
|
attendanceSelectedRange.value = range;
|
||||||
|
|
||||||
void updateProjectRange(String range) => projectSelectedRange.value = range;
|
void updateProjectRange(String range) => projectSelectedRange.value = range;
|
||||||
void toggleAttendanceChartView(bool isChart) =>
|
void toggleAttendanceChartView(bool isChart) =>
|
||||||
attendanceIsChartView.value = isChart;
|
attendanceIsChartView.value = isChart;
|
||||||
@ -163,7 +159,6 @@ class DashboardController extends GetxController {
|
|||||||
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
|
void updateMonthlyExpenseDuration(MonthlyExpenseDuration duration) {
|
||||||
selectedMonthlyExpenseDuration.value = duration;
|
selectedMonthlyExpenseDuration.value = duration;
|
||||||
|
|
||||||
// Efficient Map lookup instead of Switch
|
|
||||||
const durationMap = {
|
const durationMap = {
|
||||||
MonthlyExpenseDuration.oneMonth: 1,
|
MonthlyExpenseDuration.oneMonth: 1,
|
||||||
MonthlyExpenseDuration.threeMonths: 3,
|
MonthlyExpenseDuration.threeMonths: 3,
|
||||||
@ -176,7 +171,8 @@ class DashboardController extends GetxController {
|
|||||||
fetchMonthlyExpenses();
|
fetchMonthlyExpenses();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> refreshDashboard() => fetchAllDashboardData();
|
Future<void> refreshDashboard() =>
|
||||||
|
fetchAllDashboardData(projectController.selectedProjectId.value);
|
||||||
Future<void> refreshAttendance() => fetchRoleWiseAttendance();
|
Future<void> refreshAttendance() => fetchRoleWiseAttendance();
|
||||||
Future<void> refreshProjects() => fetchProjectProgress();
|
Future<void> refreshProjects() => fetchProjectProgress();
|
||||||
Future<void> refreshTasks() async {
|
Future<void> refreshTasks() async {
|
||||||
@ -184,150 +180,78 @@ class DashboardController extends GetxController {
|
|||||||
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
|
if (id.isNotEmpty) await fetchDashboardTasks(projectId: id);
|
||||||
}
|
}
|
||||||
|
|
||||||
// =========================
|
// --------------------------
|
||||||
// 5. DATA FETCHING (API)
|
// HELPER: Execute API call
|
||||||
// =========================
|
// --------------------------
|
||||||
|
|
||||||
/// Wrapper to reduce try-finally boilerplate for loading states
|
|
||||||
Future<void> _executeApiCall(
|
Future<void> _executeApiCall(
|
||||||
RxBool loader, Future<void> Function() apiLogic) async {
|
RxBool loaderRx, Future<void> Function() apiLogic) async {
|
||||||
loader.value = true;
|
loaderRx.value = true;
|
||||||
try {
|
try {
|
||||||
await apiLogic();
|
await apiLogic();
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe('API Call Failed: $e', level: LogLevel.error, stackTrace: stack);
|
||||||
} finally {
|
} finally {
|
||||||
loader.value = false;
|
loaderRx.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchAllDashboardData() async {
|
// --------------------------
|
||||||
final String projectId = projectController.selectedProjectId.value;
|
// API FETCHES
|
||||||
|
// --------------------------
|
||||||
|
Future<void> fetchAllDashboardData(String projectId) async {
|
||||||
if (projectId.isEmpty) return;
|
if (projectId.isEmpty) return;
|
||||||
|
_latestProjectId = projectId;
|
||||||
|
|
||||||
await Future.wait([
|
await Future.wait([
|
||||||
fetchRoleWiseAttendance(),
|
fetchRoleWiseAttendance(projectId),
|
||||||
fetchProjectProgress(),
|
fetchProjectProgress(projectId),
|
||||||
fetchDashboardTasks(projectId: projectId),
|
fetchDashboardTasks(projectId: projectId),
|
||||||
fetchDashboardTeams(projectId: projectId),
|
fetchDashboardTeams(projectId: projectId),
|
||||||
fetchPendingExpenses(),
|
fetchPendingExpenses(projectId),
|
||||||
fetchExpenseTypeReport(
|
fetchExpenseTypeReport(
|
||||||
startDate: expenseReportStartDate.value,
|
startDate: expenseReportStartDate.value,
|
||||||
endDate: expenseReportEndDate.value,
|
endDate: expenseReportEndDate.value,
|
||||||
|
projectId: projectId,
|
||||||
),
|
),
|
||||||
fetchMonthlyExpenses(),
|
fetchMonthlyExpenses(projectId: projectId),
|
||||||
fetchMasterData(),
|
fetchMasterData(),
|
||||||
fetchCollectionOverview(),
|
fetchCollectionOverview(projectId),
|
||||||
fetchPurchaseInvoiceOverview(),
|
fetchPurchaseInvoiceOverview(projectId),
|
||||||
|
fetchTodaysAttendance(projectId),
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchCollectionOverview() async {
|
// --------------------------
|
||||||
final projectId = projectController.selectedProjectId.value;
|
// Each fetch now ignores stale project responses
|
||||||
if (projectId.isEmpty) return;
|
// --------------------------
|
||||||
|
|
||||||
await _executeApiCall(isCollectionOverviewLoading, () async {
|
Future<void> fetchRoleWiseAttendance([String? projectId]) async {
|
||||||
final response =
|
final id = projectId ?? projectController.selectedProjectId.value;
|
||||||
await ApiService.getCollectionOverview(projectId: projectId);
|
|
||||||
collectionOverviewData.value =
|
|
||||||
(response?.success == true) ? response!.data : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchTodaysAttendance(String projectId) async {
|
|
||||||
await _executeApiCall(isLoadingEmployees, () async {
|
|
||||||
final response = await ApiService.getAttendanceForDashboard(projectId);
|
|
||||||
if (response != null) {
|
|
||||||
employees.value = response;
|
|
||||||
for (var emp in employees) {
|
|
||||||
uploadingStates.putIfAbsent(emp.id, () => false.obs);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchMasterData() async {
|
|
||||||
try {
|
|
||||||
final data = await ApiService.getMasterExpenseTypes();
|
|
||||||
if (data is List) {
|
|
||||||
expenseTypes.value =
|
|
||||||
data.map((e) => ExpenseTypeModel.fromJson(e)).toList();
|
|
||||||
}
|
|
||||||
} catch (_) {}
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchMonthlyExpenses({String? categoryId}) async {
|
|
||||||
await _executeApiCall(isMonthlyExpenseLoading, () async {
|
|
||||||
final response = await ApiService.getDashboardMonthlyExpensesApi(
|
|
||||||
categoryId: categoryId,
|
|
||||||
months: selectedMonthsCount.value,
|
|
||||||
);
|
|
||||||
monthlyExpenseList.value =
|
|
||||||
(response?.success == true) ? response!.data : [];
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchPurchaseInvoiceOverview() async {
|
|
||||||
final projectId = projectController.selectedProjectId.value;
|
|
||||||
if (projectId.isEmpty) return;
|
|
||||||
|
|
||||||
await _executeApiCall(isPurchaseInvoiceLoading, () async {
|
|
||||||
final response = await ApiService.getPurchaseInvoiceOverview(
|
|
||||||
projectId: projectId,
|
|
||||||
);
|
|
||||||
purchaseInvoiceOverviewData.value =
|
|
||||||
(response?.success == true) ? response!.data : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchPendingExpenses() async {
|
|
||||||
final id = projectController.selectedProjectId.value;
|
|
||||||
if (id.isEmpty) return;
|
|
||||||
|
|
||||||
await _executeApiCall(isPendingExpensesLoading, () async {
|
|
||||||
final response = await ApiService.getPendingExpensesApi(projectId: id);
|
|
||||||
pendingExpensesData.value =
|
|
||||||
(response?.success == true) ? response!.data : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchRoleWiseAttendance() async {
|
|
||||||
final id = projectController.selectedProjectId.value;
|
|
||||||
if (id.isEmpty) return;
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
|
final localId = id;
|
||||||
await _executeApiCall(isAttendanceLoading, () async {
|
await _executeApiCall(isAttendanceLoading, () async {
|
||||||
final response = await ApiService.getDashboardAttendanceOverview(
|
final response = await ApiService.getDashboardAttendanceOverview(
|
||||||
id, getAttendanceDays());
|
id, getAttendanceDays());
|
||||||
roleWiseData.value =
|
if (_latestProjectId != localId) return; // discard stale response
|
||||||
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? [];
|
roleWiseData.assignAll(
|
||||||
|
response?.map((e) => Map<String, dynamic>.from(e)).toList() ?? []);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchExpenseTypeReport(
|
Future<void> fetchProjectProgress([String? projectId]) async {
|
||||||
{required DateTime startDate, required DateTime endDate}) async {
|
final id = projectId ?? projectController.selectedProjectId.value;
|
||||||
final id = projectController.selectedProjectId.value;
|
|
||||||
if (id.isEmpty) return;
|
|
||||||
|
|
||||||
await _executeApiCall(isExpenseTypeReportLoading, () async {
|
|
||||||
final response = await ApiService.getExpenseTypeReportApi(
|
|
||||||
projectId: id,
|
|
||||||
startDate: startDate,
|
|
||||||
endDate: endDate,
|
|
||||||
);
|
|
||||||
expenseTypeReportData.value =
|
|
||||||
(response?.success == true) ? response!.data : null;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> fetchProjectProgress() async {
|
|
||||||
final id = projectController.selectedProjectId.value;
|
|
||||||
if (id.isEmpty) return;
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
|
final localId = id;
|
||||||
await _executeApiCall(isProjectLoading, () async {
|
await _executeApiCall(isProjectLoading, () async {
|
||||||
final response = await ApiService.getProjectProgress(
|
final response = await ApiService.getProjectProgress(
|
||||||
projectId: id, days: getProjectDays());
|
projectId: id, days: getProjectDays());
|
||||||
|
if (_latestProjectId != localId) return;
|
||||||
if (response?.success == true) {
|
if (response?.success == true) {
|
||||||
projectChartData.value = response!.data
|
projectChartData.assignAll(response!.data
|
||||||
.map((d) => ChartTaskData.fromProjectData(d))
|
.map((d) => ChartTaskData.fromProjectData(d))
|
||||||
.toList();
|
.toList());
|
||||||
} else {
|
} else {
|
||||||
projectChartData.clear();
|
projectChartData.clear();
|
||||||
}
|
}
|
||||||
@ -335,27 +259,115 @@ class DashboardController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchDashboardTasks({required String projectId}) async {
|
Future<void> fetchDashboardTasks({required String projectId}) async {
|
||||||
|
final localId = projectId;
|
||||||
await _executeApiCall(isTasksLoading, () async {
|
await _executeApiCall(isTasksLoading, () async {
|
||||||
final response = await ApiService.getDashboardTasks(projectId: projectId);
|
final response = await ApiService.getDashboardTasks(projectId: projectId);
|
||||||
if (response?.success == true) {
|
if (_latestProjectId != localId) return;
|
||||||
totalTasks.value = response!.data?.totalTasks ?? 0;
|
totalTasks.value = response?.data?.totalTasks ?? 0;
|
||||||
completedTasks.value = response.data?.completedTasks ?? 0;
|
completedTasks.value = response?.data?.completedTasks ?? 0;
|
||||||
} else {
|
|
||||||
totalTasks.value = 0;
|
|
||||||
completedTasks.value = 0;
|
|
||||||
}
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchDashboardTeams({required String projectId}) async {
|
Future<void> fetchDashboardTeams({required String projectId}) async {
|
||||||
|
final localId = projectId;
|
||||||
await _executeApiCall(isTeamsLoading, () async {
|
await _executeApiCall(isTeamsLoading, () async {
|
||||||
final response = await ApiService.getDashboardTeams(projectId: projectId);
|
final response = await ApiService.getDashboardTeams(projectId: projectId);
|
||||||
if (response?.success == true) {
|
if (_latestProjectId != localId) return;
|
||||||
totalEmployees.value = response!.data?.totalEmployees ?? 0;
|
totalEmployees.value = response?.data?.totalEmployees ?? 0;
|
||||||
inToday.value = response.data?.inToday ?? 0;
|
inToday.value = response?.data?.inToday ?? 0;
|
||||||
} else {
|
});
|
||||||
totalEmployees.value = 0;
|
}
|
||||||
inToday.value = 0;
|
|
||||||
|
Future<void> fetchPendingExpenses([String? projectId]) async {
|
||||||
|
final id = projectId ?? projectController.selectedProjectId.value;
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
|
final localId = id;
|
||||||
|
await _executeApiCall(isPendingExpensesLoading, () async {
|
||||||
|
final response = await ApiService.getPendingExpensesApi(projectId: id);
|
||||||
|
if (_latestProjectId != localId) return;
|
||||||
|
pendingExpensesData.value =
|
||||||
|
response?.success == true ? response!.data : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchExpenseTypeReport(
|
||||||
|
{required DateTime startDate,
|
||||||
|
required DateTime endDate,
|
||||||
|
String? projectId}) async {
|
||||||
|
final id = projectId ?? projectController.selectedProjectId.value;
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
|
final localId = id;
|
||||||
|
await _executeApiCall(isExpenseTypeReportLoading, () async {
|
||||||
|
final response = await ApiService.getExpenseTypeReportApi(
|
||||||
|
projectId: id, startDate: startDate, endDate: endDate);
|
||||||
|
if (_latestProjectId != localId) return;
|
||||||
|
expenseTypeReportData.value =
|
||||||
|
response?.success == true ? response!.data : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchMonthlyExpenses(
|
||||||
|
{String? categoryId, String? projectId}) async {
|
||||||
|
final id = projectId ?? projectController.selectedProjectId.value;
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
|
final localId = id;
|
||||||
|
await _executeApiCall(isMonthlyExpenseLoading, () async {
|
||||||
|
final response = await ApiService.getDashboardMonthlyExpensesApi(
|
||||||
|
categoryId: categoryId, months: selectedMonthsCount.value);
|
||||||
|
if (_latestProjectId != localId) return;
|
||||||
|
monthlyExpenseList
|
||||||
|
.assignAll(response?.success == true ? response!.data : []);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchMasterData() async {
|
||||||
|
await _executeApiCall(false.obs, () async {
|
||||||
|
final data = await ApiService.getMasterExpenseTypes();
|
||||||
|
if (data is List)
|
||||||
|
expenseTypes
|
||||||
|
.assignAll(data.map((e) => ExpenseTypeModel.fromJson(e)).toList());
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchCollectionOverview([String? projectId]) async {
|
||||||
|
final id = projectId ?? projectController.selectedProjectId.value;
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
|
final localId = id;
|
||||||
|
await _executeApiCall(isCollectionOverviewLoading, () async {
|
||||||
|
final response = await ApiService.getCollectionOverview(projectId: id);
|
||||||
|
if (_latestProjectId != localId) return;
|
||||||
|
collectionOverviewData.value =
|
||||||
|
response?.success == true ? response!.data : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchPurchaseInvoiceOverview([String? projectId]) async {
|
||||||
|
final id = projectId ?? projectController.selectedProjectId.value;
|
||||||
|
if (id.isEmpty) return;
|
||||||
|
|
||||||
|
final localId = id;
|
||||||
|
await _executeApiCall(isPurchaseInvoiceLoading, () async {
|
||||||
|
final response =
|
||||||
|
await ApiService.getPurchaseInvoiceOverview(projectId: id);
|
||||||
|
if (_latestProjectId != localId) return;
|
||||||
|
purchaseInvoiceOverviewData.value =
|
||||||
|
response?.success == true ? response!.data : null;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> fetchTodaysAttendance(String projectId) async {
|
||||||
|
final localId = projectId;
|
||||||
|
await _executeApiCall(isLoadingEmployees, () async {
|
||||||
|
final response = await ApiService.getAttendanceForDashboard(projectId);
|
||||||
|
if (_latestProjectId != localId) return;
|
||||||
|
|
||||||
|
employees.assignAll(response ?? []);
|
||||||
|
for (var emp in employees) {
|
||||||
|
uploadingStates.putIfAbsent(emp.id, () => false.obs);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,20 +7,18 @@ import 'package:on_field_work/model/employees/employee_details_model.dart';
|
|||||||
class EmployeesScreenController extends GetxController {
|
class EmployeesScreenController extends GetxController {
|
||||||
/// ✅ Data lists
|
/// ✅ Data lists
|
||||||
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
||||||
|
RxList<EmployeeModel> filteredEmployees = <EmployeeModel>[].obs;
|
||||||
Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
|
Rxn<EmployeeDetailsModel> selectedEmployeeDetails =
|
||||||
Rxn<EmployeeDetailsModel>();
|
Rxn<EmployeeDetailsModel>();
|
||||||
|
|
||||||
/// ✅ Loading states
|
/// ✅ Loading states
|
||||||
RxBool isLoading = false.obs;
|
RxBool isLoading = true.obs;
|
||||||
RxBool isLoadingEmployeeDetails = false.obs;
|
RxBool isLoadingEmployeeDetails = false.obs;
|
||||||
|
|
||||||
/// ✅ Selection state
|
/// ✅ Selection state
|
||||||
RxBool isAllEmployeeSelected = false.obs;
|
RxBool isAllEmployeeSelected = false.obs;
|
||||||
RxSet<String> selectedEmployeeIds = <String>{}.obs;
|
RxSet<String> selectedEmployeeIds = <String>{}.obs;
|
||||||
|
|
||||||
/// ✅ Upload state tracking (if needed later)
|
|
||||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
|
||||||
|
|
||||||
RxList<EmployeeModel> selectedEmployeePrimaryManagers = <EmployeeModel>[].obs;
|
RxList<EmployeeModel> selectedEmployeePrimaryManagers = <EmployeeModel>[].obs;
|
||||||
RxList<EmployeeModel> selectedEmployeeSecondaryManagers =
|
RxList<EmployeeModel> selectedEmployeeSecondaryManagers =
|
||||||
<EmployeeModel>[].obs;
|
<EmployeeModel>[].obs;
|
||||||
@ -31,26 +29,51 @@ class EmployeesScreenController extends GetxController {
|
|||||||
fetchAllEmployees();
|
fetchAllEmployees();
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔹 Fetch all employees (no project filter)
|
/// 🔹 Search/Filter Logic
|
||||||
|
void searchEmployees(String query) {
|
||||||
|
if (query.isEmpty) {
|
||||||
|
filteredEmployees.assignAll(employees);
|
||||||
|
} else {
|
||||||
|
final searchQuery = query.toLowerCase();
|
||||||
|
final result = employees
|
||||||
|
.where((e) =>
|
||||||
|
e.name.toLowerCase().contains(searchQuery) ||
|
||||||
|
e.email.toLowerCase().contains(searchQuery) ||
|
||||||
|
e.phoneNumber.toLowerCase().contains(searchQuery) ||
|
||||||
|
e.jobRole.toLowerCase().contains(searchQuery))
|
||||||
|
.toList();
|
||||||
|
|
||||||
|
// Sort alphabetically
|
||||||
|
result
|
||||||
|
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
||||||
|
filteredEmployees.assignAll(result);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 🔹 Fetch all employees
|
||||||
Future<void> fetchAllEmployees({String? organizationId}) async {
|
Future<void> fetchAllEmployees({String? organizationId}) async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
update(['employee_screen_controller']);
|
|
||||||
|
|
||||||
await _handleApiCall(
|
await _handleApiCall(
|
||||||
() => ApiService.getAllEmployees(organizationId: organizationId),
|
() => ApiService.getAllEmployees(organizationId: organizationId),
|
||||||
onSuccess: (data) {
|
onSuccess: (data) {
|
||||||
employees.assignAll(data.map((json) => EmployeeModel.fromJson(json)));
|
final loadedList =
|
||||||
|
data.map((json) => EmployeeModel.fromJson(json)).toList();
|
||||||
|
|
||||||
|
employees.assignAll(loadedList);
|
||||||
|
|
||||||
|
filteredEmployees.assignAll(loadedList);
|
||||||
|
|
||||||
logSafe(
|
logSafe(
|
||||||
"All Employees fetched: ${employees.length} employees loaded.",
|
"All Employees fetched: ${employees.length} employees loaded.",
|
||||||
level: LogLevel.info,
|
level: LogLevel.info,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Reset selection states when new data arrives
|
|
||||||
selectedEmployeeIds.clear();
|
selectedEmployeeIds.clear();
|
||||||
isAllEmployeeSelected.value = false;
|
isAllEmployeeSelected.value = false;
|
||||||
},
|
},
|
||||||
onEmpty: () {
|
onEmpty: () {
|
||||||
employees.clear();
|
employees.clear();
|
||||||
|
filteredEmployees.clear();
|
||||||
selectedEmployeeIds.clear();
|
selectedEmployeeIds.clear();
|
||||||
isAllEmployeeSelected.value = false;
|
isAllEmployeeSelected.value = false;
|
||||||
logSafe("No Employee data found or API call failed",
|
logSafe("No Employee data found or API call failed",
|
||||||
@ -90,16 +113,14 @@ class EmployeesScreenController extends GetxController {
|
|||||||
isLoadingEmployeeDetails.value = false;
|
isLoadingEmployeeDetails.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch reporting managers for a specific employee from /organization/hierarchy/list/:employeeId
|
/// Fetch reporting managers
|
||||||
Future<void> fetchReportingManagers(String? employeeId) async {
|
Future<void> fetchReportingManagers(String? employeeId) async {
|
||||||
if (employeeId == null || employeeId.isEmpty) return;
|
if (employeeId == null || employeeId.isEmpty) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// ✅ Always clear before new fetch (to avoid mixing old data)
|
|
||||||
selectedEmployeePrimaryManagers.clear();
|
selectedEmployeePrimaryManagers.clear();
|
||||||
selectedEmployeeSecondaryManagers.clear();
|
selectedEmployeeSecondaryManagers.clear();
|
||||||
|
|
||||||
// Fetch from existing API helper
|
|
||||||
final data = await ApiService.getOrganizationHierarchyList(employeeId);
|
final data = await ApiService.getOrganizationHierarchyList(employeeId);
|
||||||
|
|
||||||
if (data == null || data.isEmpty) {
|
if (data == null || data.isEmpty) {
|
||||||
@ -124,11 +145,8 @@ class EmployeesScreenController extends GetxController {
|
|||||||
selectedEmployeeSecondaryManagers.add(emp);
|
selectedEmployeeSecondaryManagers.add(emp);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} catch (_) {
|
} catch (_) {}
|
||||||
// ignore malformed items
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
update(['employee_screen_controller']);
|
update(['employee_screen_controller']);
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
logSafe("Error fetching reporting managers for $employeeId",
|
logSafe("Error fetching reporting managers for $employeeId",
|
||||||
@ -139,13 +157,13 @@ class EmployeesScreenController extends GetxController {
|
|||||||
/// 🔹 Clear all employee data
|
/// 🔹 Clear all employee data
|
||||||
void clearEmployees() {
|
void clearEmployees() {
|
||||||
employees.clear();
|
employees.clear();
|
||||||
|
filteredEmployees.clear();
|
||||||
selectedEmployeeIds.clear();
|
selectedEmployeeIds.clear();
|
||||||
isAllEmployeeSelected.value = false;
|
isAllEmployeeSelected.value = false;
|
||||||
logSafe("Employees cleared", level: LogLevel.info);
|
logSafe("Employees cleared", level: LogLevel.info);
|
||||||
update(['employee_screen_controller']);
|
update(['employee_screen_controller']);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔹 Generic handler for list API responses
|
|
||||||
Future<void> _handleApiCall(
|
Future<void> _handleApiCall(
|
||||||
Future<List<dynamic>?> Function() apiCall, {
|
Future<List<dynamic>?> Function() apiCall, {
|
||||||
required Function(List<dynamic>) onSuccess,
|
required Function(List<dynamic>) onSuccess,
|
||||||
@ -168,7 +186,6 @@ class EmployeesScreenController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// 🔹 Generic handler for single-object API responses
|
|
||||||
Future<void> _handleSingleApiCall(
|
Future<void> _handleSingleApiCall(
|
||||||
Future<Map<String, dynamic>?> Function() apiCall, {
|
Future<Map<String, dynamic>?> Function() apiCall, {
|
||||||
required Function(Map<String, dynamic>) onSuccess,
|
required Function(Map<String, dynamic>) onSuccess,
|
||||||
|
|||||||
@ -44,21 +44,26 @@ class AddExpenseController extends GetxController {
|
|||||||
TextEditingController get noOfPersonsController => controllers[7];
|
TextEditingController get noOfPersonsController => controllers[7];
|
||||||
TextEditingController get employeeSearchController => controllers[8];
|
TextEditingController get employeeSearchController => controllers[8];
|
||||||
|
|
||||||
|
final List<String> _transactionIdExemptIds = const [
|
||||||
|
'24e6b0df-7929-47d2-88a3-4cf14c1f28f9',
|
||||||
|
'48d9b462-5d87-4dec-8dec-2bc943943172',
|
||||||
|
'f67beee6-6763-4108-922c-03bd86b9178d',
|
||||||
|
];
|
||||||
|
|
||||||
// --- Reactive State ---
|
// --- Reactive State ---
|
||||||
final isLoading = false.obs;
|
final isLoading = false.obs;
|
||||||
final isSubmitting = false.obs;
|
final isSubmitting = false.obs;
|
||||||
final isFetchingLocation = false.obs;
|
final isFetchingLocation = false.obs;
|
||||||
final isEditMode = false.obs;
|
final isEditMode = false.obs;
|
||||||
final isSearchingEmployees = false.obs;
|
final isSearchingEmployees = false.obs;
|
||||||
|
final isTransactionIdExempted = false.obs;
|
||||||
|
|
||||||
// --- Paid By (Single + Multi Selection Support) ---
|
// --- Paid By (Single + Multi Selection Support) ---
|
||||||
|
|
||||||
// single selection
|
// single selection
|
||||||
final selectedPaidBy = Rxn<EmployeeModel>();
|
final selectedPaidBy = Rxn<EmployeeModel>();
|
||||||
|
|
||||||
|
// helper setters
|
||||||
|
|
||||||
// helper setters
|
|
||||||
void setSelectedPaidBy(EmployeeModel? emp) {
|
void setSelectedPaidBy(EmployeeModel? emp) {
|
||||||
selectedPaidBy.value = emp;
|
selectedPaidBy.value = emp;
|
||||||
}
|
}
|
||||||
@ -66,7 +71,6 @@ class AddExpenseController extends GetxController {
|
|||||||
// --- Dropdown Selections & Data ---
|
// --- Dropdown Selections & Data ---
|
||||||
final selectedPaymentMode = Rxn<PaymentModeModel>();
|
final selectedPaymentMode = Rxn<PaymentModeModel>();
|
||||||
final selectedExpenseType = Rxn<ExpenseTypeModel>();
|
final selectedExpenseType = Rxn<ExpenseTypeModel>();
|
||||||
// final selectedPaidBy = Rxn<EmployeeModel>();
|
|
||||||
final selectedProject = ''.obs;
|
final selectedProject = ''.obs;
|
||||||
final selectedTransactionDate = Rxn<DateTime>();
|
final selectedTransactionDate = Rxn<DateTime>();
|
||||||
|
|
||||||
@ -93,6 +97,7 @@ class AddExpenseController extends GetxController {
|
|||||||
employeeSearchController.addListener(
|
employeeSearchController.addListener(
|
||||||
() => searchEmployees(employeeSearchController.text),
|
() => searchEmployees(employeeSearchController.text),
|
||||||
);
|
);
|
||||||
|
ever(selectedPaymentMode, (_) => _checkTransactionIdExemption());
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -103,6 +108,12 @@ class AddExpenseController extends GetxController {
|
|||||||
super.onClose();
|
super.onClose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _checkTransactionIdExemption() {
|
||||||
|
final selectedId = selectedPaymentMode.value?.id;
|
||||||
|
isTransactionIdExempted.value =
|
||||||
|
selectedId != null && _transactionIdExemptIds.contains(selectedId);
|
||||||
|
}
|
||||||
|
|
||||||
// --- Employee Search ---
|
// --- Employee Search ---
|
||||||
Future<void> searchEmployees(String query) async {
|
Future<void> searchEmployees(String query) async {
|
||||||
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
if (query.trim().isEmpty) return employeeSearchResults.clear();
|
||||||
@ -171,6 +182,7 @@ class AddExpenseController extends GetxController {
|
|||||||
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
expenseTypes.firstWhereOrNull((e) => e.id == data['expensesTypeId']);
|
||||||
selectedPaymentMode.value =
|
selectedPaymentMode.value =
|
||||||
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
paymentModes.firstWhereOrNull((e) => e.id == data['paymentModeId']);
|
||||||
|
_checkTransactionIdExemption();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _setPaidBy(Map<String, dynamic> data) async {
|
Future<void> _setPaidBy(Map<String, dynamic> data) async {
|
||||||
@ -536,6 +548,11 @@ class AddExpenseController extends GetxController {
|
|||||||
if (amountController.text.trim().isEmpty) missing.add("Amount");
|
if (amountController.text.trim().isEmpty) missing.add("Amount");
|
||||||
if (descriptionController.text.trim().isEmpty) missing.add("Description");
|
if (descriptionController.text.trim().isEmpty) missing.add("Description");
|
||||||
|
|
||||||
|
if (!isTransactionIdExempted.value &&
|
||||||
|
transactionIdController.text.trim().isEmpty) {
|
||||||
|
missing.add("Transaction ID");
|
||||||
|
}
|
||||||
|
|
||||||
if (selectedTransactionDate.value == null) {
|
if (selectedTransactionDate.value == null) {
|
||||||
missing.add("Transaction Date");
|
missing.add("Transaction Date");
|
||||||
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
} else if (selectedTransactionDate.value!.isAfter(DateTime.now())) {
|
||||||
|
|||||||
@ -4,6 +4,9 @@ import 'package:on_field_work/helpers/services/app_logger.dart';
|
|||||||
import 'package:on_field_work/model/expense/expense_detail_model.dart';
|
import 'package:on_field_work/model/expense/expense_detail_model.dart';
|
||||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
import 'package:on_field_work/model/employees/employee_model.dart';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||||
|
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||||
|
|
||||||
|
|
||||||
class ExpenseDetailController extends GetxController {
|
class ExpenseDetailController extends GetxController {
|
||||||
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
|
final Rx<ExpenseDetailModel?> expense = Rx<ExpenseDetailModel?>(null);
|
||||||
@ -16,6 +19,22 @@ class ExpenseDetailController extends GetxController {
|
|||||||
bool _isInitialized = false;
|
bool _isInitialized = false;
|
||||||
final employeeSearchController = TextEditingController();
|
final employeeSearchController = TextEditingController();
|
||||||
final isSearchingEmployees = false.obs;
|
final isSearchingEmployees = false.obs;
|
||||||
|
|
||||||
|
// NEW: Holds the logged-in user info for permission checks
|
||||||
|
EmployeeInfo? employeeInfo;
|
||||||
|
final RxBool canSubmit = false.obs;
|
||||||
|
|
||||||
|
|
||||||
|
@override
|
||||||
|
void onInit() {
|
||||||
|
super.onInit();
|
||||||
|
_loadEmployeeInfo(); // Load employee info on init
|
||||||
|
}
|
||||||
|
|
||||||
|
void _loadEmployeeInfo() async {
|
||||||
|
final info = await LocalStorage.getEmployeeInfo();
|
||||||
|
employeeInfo = info;
|
||||||
|
}
|
||||||
|
|
||||||
/// Call this once from the screen (NOT inside build) to initialize
|
/// Call this once from the screen (NOT inside build) to initialize
|
||||||
void init(String expenseId) {
|
void init(String expenseId) {
|
||||||
@ -31,6 +50,36 @@ class ExpenseDetailController extends GetxController {
|
|||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// NEW: Logic to check if the current user can submit the expense
|
||||||
|
void checkPermissionToSubmit() {
|
||||||
|
final expenseData = expense.value;
|
||||||
|
if (employeeInfo == null || expenseData == null) {
|
||||||
|
canSubmit.value = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Status ID for 'Submit' (Hardcoded ID from the original screen logic)
|
||||||
|
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||||
|
|
||||||
|
final isCreatedByCurrentUser = employeeInfo?.id == expenseData.createdBy.id;
|
||||||
|
final nextStatusIds = expenseData.nextStatus.map((e) => e.id).toList();
|
||||||
|
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
|
||||||
|
|
||||||
|
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
|
||||||
|
|
||||||
|
logSafe(
|
||||||
|
'🐛 Checking submit permission:\n'
|
||||||
|
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
|
||||||
|
'🐛 - Expense created by ID: ${expenseData.createdBy.id}\n'
|
||||||
|
'🐛 - Next Status IDs: $nextStatusIds\n'
|
||||||
|
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
|
||||||
|
'🐛 - Final Permission Result: $result',
|
||||||
|
level: LogLevel.debug,
|
||||||
|
);
|
||||||
|
|
||||||
|
canSubmit.value = result;
|
||||||
|
}
|
||||||
|
|
||||||
/// Generic method to handle API calls with loading and error states
|
/// Generic method to handle API calls with loading and error states
|
||||||
Future<T?> _apiCallWrapper<T>(
|
Future<T?> _apiCallWrapper<T>(
|
||||||
Future<T?> Function() apiCall, String operationName) async {
|
Future<T?> Function() apiCall, String operationName) async {
|
||||||
@ -63,6 +112,8 @@ class ExpenseDetailController extends GetxController {
|
|||||||
try {
|
try {
|
||||||
expense.value = ExpenseDetailModel.fromJson(result);
|
expense.value = ExpenseDetailModel.fromJson(result);
|
||||||
logSafe("Expense details loaded successfully: ${expense.value?.id}");
|
logSafe("Expense details loaded successfully: ${expense.value?.id}");
|
||||||
|
// Call permission check after data is loaded
|
||||||
|
checkPermissionToSubmit();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage.value = 'Failed to parse expense details: $e';
|
errorMessage.value = 'Failed to parse expense details: $e';
|
||||||
logSafe("Parse error in fetchExpenseDetails: $e",
|
logSafe("Parse error in fetchExpenseDetails: $e",
|
||||||
@ -75,8 +126,6 @@ class ExpenseDetailController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// This method seems like a utility and might be better placed in a helper or utility class
|
|
||||||
// if it's used across multiple controllers. Keeping it here for now as per original code.
|
|
||||||
List<String> parsePermissionIds(dynamic permissionData) {
|
List<String> parsePermissionIds(dynamic permissionData) {
|
||||||
if (permissionData == null) return [];
|
if (permissionData == null) return [];
|
||||||
if (permissionData is List) {
|
if (permissionData is List) {
|
||||||
@ -131,8 +180,6 @@ class ExpenseDetailController extends GetxController {
|
|||||||
allEmployees.clear();
|
allEmployees.clear();
|
||||||
logSafe("No employees found.", level: LogLevel.warning);
|
logSafe("No employees found.", level: LogLevel.warning);
|
||||||
}
|
}
|
||||||
// `update()` is typically not needed for RxList directly unless you have specific GetBuilder/Obx usage that requires it
|
|
||||||
// If you are using Obx widgets, `allEmployees.assignAll` will automatically trigger a rebuild.
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update expense with reimbursement info and status
|
/// Update expense with reimbursement info and status
|
||||||
@ -191,4 +238,4 @@ class ExpenseDetailController extends GetxController {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -33,13 +33,13 @@ class PaymentRequestController extends GetxController {
|
|||||||
try {
|
try {
|
||||||
final response = await ApiService.getExpensePaymentRequestFilterApi();
|
final response = await ApiService.getExpensePaymentRequestFilterApi();
|
||||||
|
|
||||||
if (response != null && response.data != null) {
|
if (response != null) {
|
||||||
projects.assignAll(response.data!.projects ?? []);
|
projects.assignAll(response.data.projects);
|
||||||
payees.assignAll(response.data!.payees ?? []);
|
payees.assignAll(response.data.payees);
|
||||||
categories.assignAll(response.data!.expenseCategory ?? []);
|
categories.assignAll(response.data.expenseCategory);
|
||||||
currencies.assignAll(response.data!.currency ?? []);
|
currencies.assignAll(response.data.currency);
|
||||||
statuses.assignAll(response.data!.status ?? []);
|
statuses.assignAll(response.data.status);
|
||||||
createdBy.assignAll(response.data!.createdBy ?? []);
|
createdBy.assignAll(response.data.createdBy);
|
||||||
} else {
|
} else {
|
||||||
logSafe("Payment request filter API returned null",
|
logSafe("Payment request filter API returned null",
|
||||||
level: LogLevel.warning);
|
level: LogLevel.warning);
|
||||||
|
|||||||
@ -1,6 +1,7 @@
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:on_field_work/helpers/services/api_service.dart';
|
import 'package:on_field_work/helpers/services/api_service.dart';
|
||||||
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
|
import 'package:on_field_work/model/infra_project/infra_project_details.dart';
|
||||||
|
import 'package:on_field_work/model/infra_project/infra_team_list_model.dart';
|
||||||
|
|
||||||
class InfraProjectDetailsController extends GetxController {
|
class InfraProjectDetailsController extends GetxController {
|
||||||
final String projectId;
|
final String projectId;
|
||||||
@ -9,25 +10,39 @@ class InfraProjectDetailsController extends GetxController {
|
|||||||
|
|
||||||
var isLoading = true.obs;
|
var isLoading = true.obs;
|
||||||
var projectDetails = Rxn<ProjectData>();
|
var projectDetails = Rxn<ProjectData>();
|
||||||
|
var teamList = <ProjectAllocation>[].obs;
|
||||||
|
var teamLoading = true.obs;
|
||||||
var errorMessage = ''.obs;
|
var errorMessage = ''.obs;
|
||||||
|
var teamErrorMessage = ''.obs;
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
fetchProjectDetails();
|
fetchProjectDetails();
|
||||||
|
fetchProjectTeamList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Map<String, List<ProjectAllocation>> get groupedTeamByRole {
|
||||||
|
final Map<String, List<ProjectAllocation>> map = {};
|
||||||
|
for (final member in teamList) {
|
||||||
|
map.putIfAbsent(member.jobRoleId, () => []).add(member);
|
||||||
|
}
|
||||||
|
return map;
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> fetchProjectDetails() async {
|
Future<void> fetchProjectDetails() async {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
final response = await ApiService.getInfraProjectDetails(projectId: projectId);
|
final response =
|
||||||
|
await ApiService.getInfraProjectDetails(projectId: projectId);
|
||||||
|
|
||||||
if (response != null && response.success == true && response.data != null) {
|
if (response != null &&
|
||||||
|
response.success == true &&
|
||||||
|
response.data != null) {
|
||||||
projectDetails.value = response.data;
|
projectDetails.value = response.data;
|
||||||
isLoading.value = false;
|
errorMessage.value = '';
|
||||||
|
|
||||||
} else {
|
} else {
|
||||||
errorMessage.value = response?.message ?? "Failed to load project details";
|
errorMessage.value =
|
||||||
|
response?.message ?? "Failed to load project details";
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
errorMessage.value = "Error fetching project details: $e";
|
errorMessage.value = "Error fetching project details: $e";
|
||||||
@ -35,4 +50,28 @@ class InfraProjectDetailsController extends GetxController {
|
|||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> fetchProjectTeamList() async {
|
||||||
|
try {
|
||||||
|
teamLoading.value = true;
|
||||||
|
teamErrorMessage.value = '';
|
||||||
|
|
||||||
|
final response = await ApiService.getInfraProjectTeamListApi(
|
||||||
|
projectId: projectId,
|
||||||
|
includeInactive: false,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (response?.success == true && response!.data.isNotEmpty) {
|
||||||
|
teamList.assignAll(response.data);
|
||||||
|
} else {
|
||||||
|
teamList.clear();
|
||||||
|
teamErrorMessage.value = response?.message ?? "No team members found.";
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
teamList.clear();
|
||||||
|
teamErrorMessage.value = "Failed to load team members";
|
||||||
|
} finally {
|
||||||
|
teamLoading.value = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -3,7 +3,7 @@ import 'dart:convert';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||||
import 'package:on_field_work/helpers/services/permission_service.dart';
|
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||||
import 'package:on_field_work/model/user_permission.dart';
|
import 'package:on_field_work/model/user_permission.dart';
|
||||||
import 'package:on_field_work/model/employees/employee_info.dart';
|
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||||
import 'package:on_field_work/model/projects_model.dart';
|
import 'package:on_field_work/model/projects_model.dart';
|
||||||
@ -51,7 +51,7 @@ class PermissionController extends GetxController {
|
|||||||
Future<void> loadData(String token) async {
|
Future<void> loadData(String token) async {
|
||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
final userData = await PermissionService.fetchAllUserData(token);
|
final userData = await AuthService.fetchAllUserData(token);
|
||||||
_updateState(userData);
|
_updateState(userData);
|
||||||
await _storeData();
|
await _storeData();
|
||||||
logSafe("Data loaded and state updated successfully.");
|
logSafe("Data loaded and state updated successfully.");
|
||||||
|
|||||||
@ -7,14 +7,19 @@ import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
|||||||
class ProjectController extends GetxController {
|
class ProjectController extends GetxController {
|
||||||
RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs;
|
RxList<GlobalProjectModel> projects = <GlobalProjectModel>[].obs;
|
||||||
RxString selectedProjectId = ''.obs;
|
RxString selectedProjectId = ''.obs;
|
||||||
RxBool isProjectListExpanded = false.obs;
|
|
||||||
RxBool isProjectSelectionExpanded = false.obs;
|
|
||||||
|
|
||||||
|
RxBool isProjectSelectionExpanded = false.obs;
|
||||||
|
RxBool isProjectListExpanded = false.obs;
|
||||||
RxBool isProjectDropdownExpanded = false.obs;
|
RxBool isProjectDropdownExpanded = false.obs;
|
||||||
|
|
||||||
RxBool isLoading = true.obs;
|
RxBool isLoading = true.obs;
|
||||||
RxBool isLoadingProjects = true.obs;
|
RxBool isLoadingProjects = true.obs;
|
||||||
|
|
||||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// Current selected project
|
||||||
|
// --------------------------
|
||||||
GlobalProjectModel? get selectedProject {
|
GlobalProjectModel? get selectedProject {
|
||||||
if (selectedProjectId.value.isEmpty) return null;
|
if (selectedProjectId.value.isEmpty) return null;
|
||||||
return projects.firstWhereOrNull((p) => p.id == selectedProjectId.value);
|
return projects.firstWhereOrNull((p) => p.id == selectedProjectId.value);
|
||||||
@ -26,58 +31,63 @@ class ProjectController extends GetxController {
|
|||||||
fetchProjects();
|
fetchProjects();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --------------------------
|
||||||
|
// Clear all projects & UI states
|
||||||
|
// --------------------------
|
||||||
void clearProjects() {
|
void clearProjects() {
|
||||||
projects.clear();
|
projects.clear();
|
||||||
selectedProjectId.value = '';
|
selectedProjectId.value = '';
|
||||||
|
|
||||||
isProjectSelectionExpanded.value = false;
|
isProjectSelectionExpanded.value = false;
|
||||||
isProjectListExpanded.value = false;
|
isProjectListExpanded.value = false;
|
||||||
isProjectDropdownExpanded.value = false;
|
isProjectDropdownExpanded.value = false;
|
||||||
isLoadingProjects.value = false;
|
|
||||||
isLoading.value = false;
|
isLoading.value = false;
|
||||||
|
isLoadingProjects.value = false;
|
||||||
uploadingStates.clear();
|
uploadingStates.clear();
|
||||||
|
|
||||||
LocalStorage.saveString('selectedProjectId', '');
|
LocalStorage.saveString('selectedProjectId', '');
|
||||||
|
|
||||||
logSafe("Projects cleared and UI states reset.");
|
logSafe("Projects cleared and UI states reset.");
|
||||||
update();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetches projects and initializes selected project.
|
// --------------------------
|
||||||
|
// Fetch projects from API
|
||||||
|
// --------------------------
|
||||||
Future<void> fetchProjects() async {
|
Future<void> fetchProjects() async {
|
||||||
isLoadingProjects.value = true;
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
isLoadingProjects.value = true;
|
||||||
|
|
||||||
final response = await ApiService.getGlobalProjects();
|
try {
|
||||||
|
final response = await ApiService.getGlobalProjects();
|
||||||
|
|
||||||
if (response != null && response.isNotEmpty) {
|
if (response != null && response.isNotEmpty) {
|
||||||
projects.assignAll(
|
projects.assignAll(response.map((json) => GlobalProjectModel.fromJson(json)).toList());
|
||||||
response.map((json) => GlobalProjectModel.fromJson(json)).toList(),
|
|
||||||
);
|
|
||||||
|
|
||||||
String? savedId = LocalStorage.getString('selectedProjectId');
|
// Load previously saved project
|
||||||
if (savedId != null && projects.any((p) => p.id == savedId)) {
|
String? savedId = LocalStorage.getString('selectedProjectId');
|
||||||
selectedProjectId.value = savedId;
|
if (savedId != null && projects.any((p) => p.id == savedId)) {
|
||||||
|
selectedProjectId.value = savedId;
|
||||||
|
} else {
|
||||||
|
selectedProjectId.value = projects.first.id.toString();
|
||||||
|
LocalStorage.saveString('selectedProjectId', selectedProjectId.value);
|
||||||
|
}
|
||||||
|
|
||||||
|
logSafe("Projects fetched: ${projects.length}");
|
||||||
} else {
|
} else {
|
||||||
selectedProjectId.value = projects.first.id.toString();
|
logSafe("No projects found or API call failed.", level: LogLevel.warning);
|
||||||
LocalStorage.saveString('selectedProjectId', selectedProjectId.value);
|
|
||||||
}
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
isProjectSelectionExpanded.value = false;
|
logSafe("Error fetching projects: $e", level: LogLevel.error, stackTrace: stack);
|
||||||
logSafe("Projects fetched: ${projects.length}");
|
} finally {
|
||||||
} else {
|
isLoading.value = false;
|
||||||
logSafe("No Global projects found or API call failed.", level: LogLevel.warning);
|
isLoadingProjects.value = false;
|
||||||
}
|
}
|
||||||
|
|
||||||
isLoadingProjects.value = false;
|
|
||||||
isLoading.value = false;
|
|
||||||
update(['dashboard_controller']);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> updateSelectedProject(String projectId) async {
|
Future<void> updateSelectedProject(String projectId) async {
|
||||||
|
if (selectedProjectId.value == projectId) return;
|
||||||
selectedProjectId.value = projectId;
|
selectedProjectId.value = projectId;
|
||||||
await LocalStorage.saveString('selectedProjectId', projectId);
|
await LocalStorage.saveString('selectedProjectId', projectId);
|
||||||
logSafe("Selected project updated to $projectId");
|
logSafe("Selected project updated to $projectId");
|
||||||
update(['selected_project']);
|
isProjectSelectionExpanded.value = false;
|
||||||
|
update();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,8 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
RxList<EmployeeModel> employees = <EmployeeModel>[].obs;
|
||||||
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
|
RxList<EmployeeModel> selectedEmployees = <EmployeeModel>[].obs;
|
||||||
List<EmployeeModel> allEmployeesCache = [];
|
List<EmployeeModel> allEmployeesCache = [];
|
||||||
List<TaskPlanningDetailsModel> dailyTasks = [];
|
RxList<TaskPlanningDetailsModel> dailyTasks =
|
||||||
|
<TaskPlanningDetailsModel>[].obs;
|
||||||
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
RxMap<String, RxBool> uploadingStates = <String, RxBool>{}.obs;
|
||||||
|
|
||||||
MyFormValidator basicValidator = MyFormValidator();
|
MyFormValidator basicValidator = MyFormValidator();
|
||||||
@ -27,6 +28,7 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
RxMap<String, RxBool> buildingLoadingStates = <String, RxBool>{}.obs;
|
RxMap<String, RxBool> buildingLoadingStates = <String, RxBool>{}.obs;
|
||||||
final Set<String> buildingsWithDetails = <String>{};
|
final Set<String> buildingsWithDetails = <String>{};
|
||||||
|
|
||||||
|
RxMap<String, RxDouble> todaysAssignedMap = <String, RxDouble>{}.obs;
|
||||||
@override
|
@override
|
||||||
void onInit() {
|
void onInit() {
|
||||||
super.onInit();
|
super.onInit();
|
||||||
@ -72,6 +74,8 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
required int plannedTask,
|
required int plannedTask,
|
||||||
required String description,
|
required String description,
|
||||||
required List<String> taskTeam,
|
required List<String> taskTeam,
|
||||||
|
required String buildingId,
|
||||||
|
required String projectId,
|
||||||
DateTime? assignmentDate,
|
DateTime? assignmentDate,
|
||||||
String? organizationId,
|
String? organizationId,
|
||||||
String? serviceId,
|
String? serviceId,
|
||||||
@ -93,6 +97,8 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
|
|
||||||
if (response == true) {
|
if (response == true) {
|
||||||
logSafe("Task assigned successfully", level: LogLevel.info);
|
logSafe("Task assigned successfully", level: LogLevel.info);
|
||||||
|
await fetchBuildingInfra(buildingId, projectId, serviceId);
|
||||||
|
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Success",
|
title: "Success",
|
||||||
message: "Task assigned successfully!",
|
message: "Task assigned successfully!",
|
||||||
@ -123,18 +129,17 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
final infraData = infraResponse?['data'] as List<dynamic>?;
|
final infraData = infraResponse?['data'] as List<dynamic>?;
|
||||||
|
|
||||||
if (infraData == null || infraData.isEmpty) {
|
if (infraData == null || infraData.isEmpty) {
|
||||||
dailyTasks = [];
|
dailyTasks.clear(); //reactive clear
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter buildings with 0 planned & completed work
|
|
||||||
final filteredBuildings = infraData.where((b) {
|
final filteredBuildings = infraData.where((b) {
|
||||||
final planned = (b['plannedWork'] as num?)?.toDouble() ?? 0;
|
final planned = (b['plannedWork'] as num?)?.toDouble() ?? 0;
|
||||||
final completed = (b['completedWork'] as num?)?.toDouble() ?? 0;
|
final completed = (b['completedWork'] as num?)?.toDouble() ?? 0;
|
||||||
return planned > 0 || completed > 0;
|
return planned > 0 || completed > 0;
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
dailyTasks = filteredBuildings.map((buildingJson) {
|
final mapped = filteredBuildings.map((buildingJson) {
|
||||||
final building = Building(
|
final building = Building(
|
||||||
id: buildingJson['id'],
|
id: buildingJson['id'],
|
||||||
name: buildingJson['buildingName'],
|
name: buildingJson['buildingName'],
|
||||||
@ -157,30 +162,31 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
);
|
);
|
||||||
}).toList();
|
}).toList();
|
||||||
|
|
||||||
|
dailyTasks.assignAll(mapped);
|
||||||
|
|
||||||
buildingLoadingStates.clear();
|
buildingLoadingStates.clear();
|
||||||
buildingsWithDetails.clear();
|
buildingsWithDetails.clear();
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logSafe("Error fetching daily task data",
|
logSafe(
|
||||||
level: LogLevel.error, error: e, stackTrace: stack);
|
"Error fetching daily task data",
|
||||||
|
level: LogLevel.error,
|
||||||
|
error: e,
|
||||||
|
stackTrace: stack,
|
||||||
|
);
|
||||||
} finally {
|
} finally {
|
||||||
isFetchingTasks.value = false;
|
isFetchingTasks.value = false;
|
||||||
update();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Fetch full infra for a single building (floors, workAreas, workItems).
|
/// Fetch full infra for a single building (lazy)
|
||||||
/// Called lazily when user expands a building in the UI.
|
|
||||||
Future<void> fetchBuildingInfra(
|
Future<void> fetchBuildingInfra(
|
||||||
String buildingId, String projectId, String? serviceId) async {
|
String buildingId, String projectId, String? serviceId) async {
|
||||||
if (buildingId.isEmpty) return;
|
if (buildingId.isEmpty) return;
|
||||||
|
|
||||||
// mark loading
|
|
||||||
buildingLoadingStates.putIfAbsent(buildingId, () => true.obs);
|
buildingLoadingStates.putIfAbsent(buildingId, () => true.obs);
|
||||||
buildingLoadingStates[buildingId]!.value = true;
|
buildingLoadingStates[buildingId]!.value = true; // Rx change is enough
|
||||||
update();
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Re-use getInfraDetails and find the building entry for the requested buildingId
|
|
||||||
final infraResponse =
|
final infraResponse =
|
||||||
await ApiService.getInfraDetails(projectId, serviceId: serviceId);
|
await ApiService.getInfraDetails(projectId, serviceId: serviceId);
|
||||||
final infraData = infraResponse?['data'] as List<dynamic>? ?? [];
|
final infraData = infraResponse?['data'] as List<dynamic>? ?? [];
|
||||||
@ -196,7 +202,6 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Build floors & workAreas for this building
|
|
||||||
final building = Building(
|
final building = Building(
|
||||||
id: buildingJson['id'],
|
id: buildingJson['id'],
|
||||||
name: buildingJson['buildingName'],
|
name: buildingJson['buildingName'],
|
||||||
@ -211,7 +216,7 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
return WorkArea(
|
return WorkArea(
|
||||||
id: areaJson['id'],
|
id: areaJson['id'],
|
||||||
areaName: areaJson['areaName'],
|
areaName: areaJson['areaName'],
|
||||||
workItems: [], // will populate later
|
workItems: [],
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
@ -220,7 +225,6 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
completedWork: (buildingJson['completedWork'] as num?)?.toDouble() ?? 0,
|
completedWork: (buildingJson['completedWork'] as num?)?.toDouble() ?? 0,
|
||||||
);
|
);
|
||||||
|
|
||||||
// For each workArea, fetch its work items and populate
|
|
||||||
await Future.wait(
|
await Future.wait(
|
||||||
building.floors.expand((f) => f.workAreas).map((area) async {
|
building.floors.expand((f) => f.workAreas).map((area) async {
|
||||||
try {
|
try {
|
||||||
@ -255,7 +259,6 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
}
|
}
|
||||||
}));
|
}));
|
||||||
|
|
||||||
// Merge/replace the building into dailyTasks
|
|
||||||
bool merged = false;
|
bool merged = false;
|
||||||
for (var t in dailyTasks) {
|
for (var t in dailyTasks) {
|
||||||
final idx = t.buildings
|
final idx = t.buildings
|
||||||
@ -267,7 +270,6 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (!merged) {
|
if (!merged) {
|
||||||
// If not present, add a new TaskPlanningDetailsModel wrapper (fallback)
|
|
||||||
dailyTasks.add(TaskPlanningDetailsModel(
|
dailyTasks.add(TaskPlanningDetailsModel(
|
||||||
id: building.id,
|
id: building.id,
|
||||||
name: building.name,
|
name: building.name,
|
||||||
@ -280,7 +282,6 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
));
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Mark as loaded
|
|
||||||
buildingsWithDetails.add(buildingId.toString());
|
buildingsWithDetails.add(buildingId.toString());
|
||||||
} catch (e, stack) {
|
} catch (e, stack) {
|
||||||
logSafe("Error fetching infra for building $buildingId",
|
logSafe("Error fetching infra for building $buildingId",
|
||||||
@ -288,7 +289,7 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
} finally {
|
} finally {
|
||||||
buildingLoadingStates.putIfAbsent(buildingId, () => false.obs);
|
buildingLoadingStates.putIfAbsent(buildingId, () => false.obs);
|
||||||
buildingLoadingStates[buildingId]!.value = false;
|
buildingLoadingStates[buildingId]!.value = false;
|
||||||
update();
|
update(); // dailyTasks mutated
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -361,7 +362,7 @@ class DailyTaskPlanningController extends GetxController {
|
|||||||
}
|
}
|
||||||
} finally {
|
} finally {
|
||||||
isFetchingEmployees.value = false;
|
isFetchingEmployees.value = false;
|
||||||
update();
|
// no update(): RxLists/RxBools notify observers
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,14 +1,12 @@
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||||
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
|
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
|
||||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||||
import 'package:on_field_work/controller/permission_controller.dart';
|
import 'package:on_field_work/controller/permission_controller.dart';
|
||||||
|
|
||||||
class TenantSelectionController extends GetxController {
|
class TenantSelectionController extends GetxController {
|
||||||
final TenantService _tenantService = TenantService();
|
|
||||||
|
|
||||||
// Tenant list
|
// Tenant list
|
||||||
final tenants = <Tenant>[].obs;
|
final tenants = <Tenant>[].obs;
|
||||||
|
|
||||||
@ -32,10 +30,11 @@ class TenantSelectionController extends GetxController {
|
|||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
isAutoSelecting.value = true; // show splash during auto-selection
|
isAutoSelecting.value = true; // show splash during auto-selection
|
||||||
try {
|
try {
|
||||||
final data = await _tenantService.getTenants();
|
final data = await AuthService.getTenants();
|
||||||
if (data == null || data.isEmpty) {
|
if (data == null || data.isEmpty) {
|
||||||
tenants.clear();
|
tenants.clear();
|
||||||
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
|
logSafe("⚠️ No tenants found for the user.", level: LogLevel.warning);
|
||||||
|
await LocalStorage.logout();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,7 +86,7 @@ class TenantSelectionController extends GetxController {
|
|||||||
try {
|
try {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
|
|
||||||
final success = await _tenantService.selectTenant(tenantId);
|
final success = await AuthService.selectTenant(tenantId);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
title: "Error",
|
title: "Error",
|
||||||
@ -99,7 +98,7 @@ class TenantSelectionController extends GetxController {
|
|||||||
|
|
||||||
// Update tenant & persist
|
// Update tenant & persist
|
||||||
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
||||||
TenantService.setSelectedTenant(selectedTenant);
|
AuthService.setSelectedTenant(selectedTenant);
|
||||||
selectedTenantId.value = tenantId;
|
selectedTenantId.value = tenantId;
|
||||||
await LocalStorage.setRecentTenantId(tenantId);
|
await LocalStorage.setRecentTenantId(tenantId);
|
||||||
|
|
||||||
@ -131,6 +130,6 @@ class TenantSelectionController extends GetxController {
|
|||||||
/// Clear tenant selection
|
/// Clear tenant selection
|
||||||
void _clearSelection() {
|
void _clearSelection() {
|
||||||
selectedTenantId.value = null;
|
selectedTenantId.value = null;
|
||||||
TenantService.currentTenant = null;
|
AuthService.currentTenant = null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,13 +1,12 @@
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||||
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
|
import 'package:on_field_work/helpers/widgets/my_snackbar.dart';
|
||||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||||
import 'package:on_field_work/controller/permission_controller.dart';
|
import 'package:on_field_work/controller/permission_controller.dart';
|
||||||
|
|
||||||
class TenantSwitchController extends GetxController {
|
class TenantSwitchController extends GetxController {
|
||||||
final TenantService _tenantService = TenantService();
|
|
||||||
|
|
||||||
final tenants = <Tenant>[].obs;
|
final tenants = <Tenant>[].obs;
|
||||||
final isLoading = false.obs;
|
final isLoading = false.obs;
|
||||||
@ -23,7 +22,7 @@ class TenantSwitchController extends GetxController {
|
|||||||
Future<void> loadTenants() async {
|
Future<void> loadTenants() async {
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
final data = await _tenantService.getTenants();
|
final data = await AuthService.getTenants();
|
||||||
if (data == null || data.isEmpty) {
|
if (data == null || data.isEmpty) {
|
||||||
tenants.clear();
|
tenants.clear();
|
||||||
logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning);
|
logSafe("⚠️ No tenants available for switching.", level: LogLevel.warning);
|
||||||
@ -33,7 +32,7 @@ class TenantSwitchController extends GetxController {
|
|||||||
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
|
tenants.value = data.map((e) => Tenant.fromJson(e)).toList();
|
||||||
|
|
||||||
// Keep current tenant as selected
|
// Keep current tenant as selected
|
||||||
selectedTenantId.value = TenantService.currentTenant?.id;
|
selectedTenantId.value = AuthService.currentTenant?.id;
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st);
|
logSafe("❌ Exception in loadTenants", level: LogLevel.error, error: e, stackTrace: st);
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
@ -48,11 +47,11 @@ class TenantSwitchController extends GetxController {
|
|||||||
|
|
||||||
/// Switch to a different tenant and navigate fully
|
/// Switch to a different tenant and navigate fully
|
||||||
Future<void> switchTenant(String tenantId) async {
|
Future<void> switchTenant(String tenantId) async {
|
||||||
if (TenantService.currentTenant?.id == tenantId) return;
|
if (AuthService.currentTenant?.id == tenantId) return;
|
||||||
|
|
||||||
isLoading.value = true;
|
isLoading.value = true;
|
||||||
try {
|
try {
|
||||||
final success = await _tenantService.selectTenant(tenantId);
|
final success = await AuthService.selectTenant(tenantId);
|
||||||
if (!success) {
|
if (!success) {
|
||||||
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
|
logSafe("❌ Tenant switch failed: $tenantId", level: LogLevel.warning);
|
||||||
showAppSnackbar(
|
showAppSnackbar(
|
||||||
@ -64,7 +63,7 @@ class TenantSwitchController extends GetxController {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
final selectedTenant = tenants.firstWhere((t) => t.id == tenantId);
|
||||||
TenantService.setSelectedTenant(selectedTenant);
|
AuthService.setSelectedTenant(selectedTenant);
|
||||||
selectedTenantId.value = tenantId;
|
selectedTenantId.value = tenantId;
|
||||||
|
|
||||||
// Persist recent tenant
|
// Persist recent tenant
|
||||||
|
|||||||
@ -3,8 +3,7 @@ class ApiEndpoints {
|
|||||||
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
// static const String baseUrl = "https://api.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
// static const String baseUrl = "https://devapi.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
|
// static const String baseUrl = "https://mapi.marcoaiot.com/api";
|
||||||
// static const String baseUrl = "https://api.onfieldwork.com/api";
|
// static const String baseUrl = "https://api.onfieldwork.com/api";
|
||||||
|
|
||||||
|
|
||||||
static const String getMasterCurrencies = "/Master/currencies/list";
|
static const String getMasterCurrencies = "/Master/currencies/list";
|
||||||
static const String getMasterExpensesCategories =
|
static const String getMasterExpensesCategories =
|
||||||
@ -48,7 +47,8 @@ class ApiEndpoints {
|
|||||||
static const String getProjects = "/project/list";
|
static const String getProjects = "/project/list";
|
||||||
static const String getGlobalProjects = "/project/list/basic";
|
static const String getGlobalProjects = "/project/list/basic";
|
||||||
static const String getTodaysAttendance = "/attendance/project/team";
|
static const String getTodaysAttendance = "/attendance/project/team";
|
||||||
static const String getAttendanceForDashboard = "/dashboard/get/attendance/employee/:projectId";
|
static const String getAttendanceForDashboard =
|
||||||
|
"/dashboard/get/attendance/employee/:projectId";
|
||||||
static const String getAttendanceLogs = "/attendance/project/log";
|
static const String getAttendanceLogs = "/attendance/project/log";
|
||||||
static const String getAttendanceLogView = "/attendance/log/attendance";
|
static const String getAttendanceLogView = "/attendance/log/attendance";
|
||||||
static const String getRegularizationLogs = "/attendance/regularize";
|
static const String getRegularizationLogs = "/attendance/regularize";
|
||||||
@ -142,7 +142,6 @@ class ApiEndpoints {
|
|||||||
static const String manageOrganizationHierarchy =
|
static const String manageOrganizationHierarchy =
|
||||||
"/organization/hierarchy/manage";
|
"/organization/hierarchy/manage";
|
||||||
|
|
||||||
|
|
||||||
// Service Project Module API Endpoints
|
// Service Project Module API Endpoints
|
||||||
static const String getServiceProjectsList = "/serviceproject/list";
|
static const String getServiceProjectsList = "/serviceproject/list";
|
||||||
static const String getServiceProjectDetail = "/serviceproject/details";
|
static const String getServiceProjectDetail = "/serviceproject/details";
|
||||||
@ -151,10 +150,14 @@ class ApiEndpoints {
|
|||||||
"/serviceproject/job/details";
|
"/serviceproject/job/details";
|
||||||
static const String editServiceProjectJob = "/serviceproject/job/edit";
|
static const String editServiceProjectJob = "/serviceproject/job/edit";
|
||||||
static const String createServiceProjectJob = "/serviceproject/job/create";
|
static const String createServiceProjectJob = "/serviceproject/job/create";
|
||||||
static const String serviceProjectUpateJobAttendance = "/serviceproject/job/attendance";
|
static const String serviceProjectUpateJobAttendance =
|
||||||
static const String serviceProjectUpateJobAttendanceLog = "/serviceproject/job/attendance/log";
|
"/serviceproject/job/attendance";
|
||||||
static const String getServiceProjectUpateJobAllocationList = "/serviceproject/get/allocation/list";
|
static const String serviceProjectUpateJobAttendanceLog =
|
||||||
static const String manageServiceProjectUpateJobAllocation = "/serviceproject/manage/allocation";
|
"/serviceproject/job/attendance/log";
|
||||||
|
static const String getServiceProjectUpateJobAllocationList =
|
||||||
|
"/serviceproject/get/allocation/list";
|
||||||
|
static const String manageServiceProjectUpateJobAllocation =
|
||||||
|
"/serviceproject/manage/allocation";
|
||||||
static const String getTeamRoles = "/master/team-roles/list";
|
static const String getTeamRoles = "/master/team-roles/list";
|
||||||
static const String getServiceProjectBranches = "/serviceproject/branch/list";
|
static const String getServiceProjectBranches = "/serviceproject/branch/list";
|
||||||
|
|
||||||
@ -167,4 +170,6 @@ class ApiEndpoints {
|
|||||||
// Infra Project Module API Endpoints
|
// Infra Project Module API Endpoints
|
||||||
static const String getInfraProjectsList = "/project/list";
|
static const String getInfraProjectsList = "/project/list";
|
||||||
static const String getInfraProjectDetail = "/project/details";
|
static const String getInfraProjectDetail = "/project/details";
|
||||||
|
static const String getInfraProjectTeamList = "/project/allocation";
|
||||||
|
static const String assignInfraProjectAllocation = "/project/allocation";
|
||||||
}
|
}
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@ -38,11 +38,16 @@ Future<void> initializeApp() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
|
/// 🔹 AUTH TOKEN HANDLER
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
Future<void> _handleAuthTokens() async {
|
Future<void> _handleAuthTokens() async {
|
||||||
final refreshToken = await LocalStorage.getRefreshToken();
|
final refreshToken = await LocalStorage.getRefreshToken();
|
||||||
|
|
||||||
if (refreshToken?.isNotEmpty ?? false) {
|
if (refreshToken?.isNotEmpty ?? false) {
|
||||||
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
logSafe("🔁 Refresh token found. Attempting to refresh JWT...");
|
||||||
final success = await AuthService.refreshToken();
|
final success = await AuthService.refreshToken();
|
||||||
|
|
||||||
if (!success) {
|
if (!success) {
|
||||||
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
|
logSafe("⚠️ Refresh token invalid or expired. User must login again.");
|
||||||
}
|
}
|
||||||
@ -51,43 +56,67 @@ Future<void> _handleAuthTokens() async {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
|
/// 🔹 UI SETUP
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
Future<void> _setupUI() async {
|
Future<void> _setupUI() async {
|
||||||
setPathUrlStrategy();
|
setPathUrlStrategy();
|
||||||
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
await SystemChrome.setEnabledSystemUIMode(SystemUiMode.edgeToEdge);
|
||||||
logSafe("💡 UI setup completed with default system behavior.");
|
logSafe("💡 UI setup completed.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
|
/// 🔹 FIREBASE + GEMINI SETUP
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
Future<void> _setupFirebase() async {
|
Future<void> _setupFirebase() async {
|
||||||
|
// Firebase Core
|
||||||
await Firebase.initializeApp();
|
await Firebase.initializeApp();
|
||||||
logSafe("💡 Firebase initialized.");
|
logSafe("🔥 Firebase initialized.");
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
|
/// 🔹 LOCAL STORAGE SETUP
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
Future<void> _setupLocalStorage() async {
|
Future<void> _setupLocalStorage() async {
|
||||||
if (!LocalStorage.isInitialized) {
|
if (!LocalStorage.isInitialized) {
|
||||||
await LocalStorage.init();
|
await LocalStorage.init();
|
||||||
logSafe("💡 Local storage initialized.");
|
logSafe("💾 Local storage initialized.");
|
||||||
} else {
|
} else {
|
||||||
logSafe("ℹ️ Local storage already initialized, skipping.");
|
logSafe("ℹ️ Local storage already initialized. Skipping.");
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
|
/// 🔹 DEVICE INFO
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
Future<void> _setupDeviceInfo() async {
|
Future<void> _setupDeviceInfo() async {
|
||||||
final deviceInfoService = DeviceInfoService();
|
final deviceInfoService = DeviceInfoService();
|
||||||
await deviceInfoService.init();
|
await deviceInfoService.init();
|
||||||
logSafe("📱 Device Info: ${deviceInfoService.deviceData}");
|
|
||||||
|
logSafe("📱 Device Info Loaded: ${deviceInfoService.deviceData}");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
|
/// 🔹 THEME SETUP
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
Future<void> _setupTheme() async {
|
Future<void> _setupTheme() async {
|
||||||
await ThemeCustomizer.init();
|
await ThemeCustomizer.init();
|
||||||
logSafe("💡 Theme customizer initialized.");
|
logSafe("🎨 Theme customizer initialized.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
|
/// 🔹 FIREBASE CLOUD MESSAGING (PUSH)
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
Future<void> _setupFirebaseMessaging() async {
|
Future<void> _setupFirebaseMessaging() async {
|
||||||
await FirebaseNotificationService().initialize();
|
await FirebaseNotificationService().initialize();
|
||||||
logSafe("💡 Firebase Messaging initialized.");
|
logSafe("📨 Firebase Messaging initialized.");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
|
/// 🔹 FINAL APP STYLE
|
||||||
|
/// ---------------------------------------------------------------------------
|
||||||
void _finalizeAppStyle() {
|
void _finalizeAppStyle() {
|
||||||
AppStyle.init();
|
AppStyle.init();
|
||||||
logSafe("💡 AppStyle initialized.");
|
logSafe("🎯 AppStyle initialized.");
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,27 +1,47 @@
|
|||||||
import 'dart:convert';
|
import 'dart:convert';
|
||||||
import 'package:http/http.dart' as http;
|
import 'package:http/http.dart' as http;
|
||||||
|
import 'package:get/get.dart';
|
||||||
|
import 'package:on_field_work/controller/project_controller.dart';
|
||||||
import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
||||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||||
|
import 'package:on_field_work/helpers/utils/encryption_helper.dart';
|
||||||
|
import 'package:on_field_work/model/tenant/tenant_list_model.dart';
|
||||||
|
import 'package:on_field_work/model/user_permission.dart';
|
||||||
|
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||||
|
import 'package:on_field_work/model/projects_model.dart';
|
||||||
|
|
||||||
|
// Enum for standardizing HTTP methods within the service
|
||||||
|
enum _HttpMethod { get, post }
|
||||||
|
|
||||||
class AuthService {
|
class AuthService {
|
||||||
static const String _baseUrl = ApiEndpoints.baseUrl;
|
static const String _baseUrl = ApiEndpoints.baseUrl;
|
||||||
static const Map<String, String> _headers = {
|
static const Map<String, String> _defaultHeaders = {
|
||||||
'Content-Type': 'application/json',
|
'Content-Type': 'application/json',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// AuthService properties
|
||||||
static bool isLoggedIn = false;
|
static bool isLoggedIn = false;
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
/* Logout API */
|
// TenantService properties
|
||||||
/* -------------------------------------------------------------------------- */
|
static Tenant? currentTenant;
|
||||||
|
|
||||||
|
// PermissionService properties
|
||||||
|
static final Map<String, Map<String, dynamic>> _userDataCache = {};
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* AUTH METHODS */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/// Logs the user out by calling the logout API.
|
||||||
static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
|
static Future<bool> logoutApi(String refreshToken, String fcmToken) async {
|
||||||
try {
|
try {
|
||||||
final body = {
|
final body = {"refreshToken": refreshToken, "fcmToken": fcmToken};
|
||||||
"refreshToken": refreshToken,
|
final response = await _networkRequest(
|
||||||
"fcmToken": fcmToken,
|
path: "/auth/logout",
|
||||||
};
|
method: _HttpMethod.post,
|
||||||
|
body: body,
|
||||||
final response = await _post("/auth/logout", body);
|
);
|
||||||
|
|
||||||
if (response != null && response['statusCode'] == 200) {
|
if (response != null && response['statusCode'] == 200) {
|
||||||
logSafe("✅ Logout API successful");
|
logSafe("✅ Logout API successful");
|
||||||
@ -37,10 +57,7 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/// Registers or updates the Firebase Cloud Messaging token.
|
||||||
/* Public Methods */
|
|
||||||
/* -------------------------------------------------------------------------- */
|
|
||||||
|
|
||||||
static Future<bool> registerDeviceToken(String fcmToken) async {
|
static Future<bool> registerDeviceToken(String fcmToken) async {
|
||||||
final token = await LocalStorage.getJwtToken();
|
final token = await LocalStorage.getJwtToken();
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
@ -50,38 +67,36 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final body = {"fcmToken": fcmToken};
|
final body = {"fcmToken": fcmToken};
|
||||||
final headers = {
|
final response = await _networkRequest(
|
||||||
..._headers,
|
path: "/auth/set/device-token",
|
||||||
'Authorization': 'Bearer $token',
|
method: _HttpMethod.post,
|
||||||
};
|
body: body,
|
||||||
final endpoint = "$_baseUrl/auth/set/device-token";
|
authToken: token,
|
||||||
|
);
|
||||||
|
|
||||||
// 🔹 Log request details
|
if (response != null && response['success'] == true) {
|
||||||
logSafe("📡 Device Token API Request");
|
|
||||||
logSafe("➡️ Endpoint: $endpoint");
|
|
||||||
logSafe("➡️ Headers: ${jsonEncode(headers)}");
|
|
||||||
logSafe("➡️ Payload: ${jsonEncode(body)}");
|
|
||||||
|
|
||||||
final data = await _post("/auth/set/device-token", body, authToken: token);
|
|
||||||
|
|
||||||
if (data != null && data['success'] == true) {
|
|
||||||
logSafe("✅ Device token registered successfully.");
|
logSafe("✅ Device token registered successfully.");
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
logSafe("⚠️ Failed to register device token: ${data?['message']}",
|
logSafe("⚠️ Failed to register device token: ${response?['message']}",
|
||||||
level: LogLevel.warning);
|
level: LogLevel.warning);
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Handles user login with email/password.
|
||||||
|
/// Returns error map on failure, or null on success.
|
||||||
static Future<Map<String, String>?> loginUser(
|
static Future<Map<String, String>?> loginUser(
|
||||||
Map<String, dynamic> data) async {
|
Map<String, dynamic> data) async {
|
||||||
logSafe("Attempting login...");
|
logSafe("Attempting login...");
|
||||||
logSafe("Login payload (raw): $data");
|
final responseData = await _networkRequest(
|
||||||
logSafe("Login payload (JSON): ${jsonEncode(data)}");
|
path: "/auth/app/login",
|
||||||
|
method: _HttpMethod.post,
|
||||||
|
body: data,
|
||||||
|
);
|
||||||
|
|
||||||
final responseData = await _post("/auth/app/login", data);
|
if (responseData == null) {
|
||||||
if (responseData == null)
|
|
||||||
return {"error": "Network error. Please check your connection."};
|
return {"error": "Network error. Please check your connection."};
|
||||||
|
}
|
||||||
|
|
||||||
if (responseData['data'] != null) {
|
if (responseData['data'] != null) {
|
||||||
await _handleLoginSuccess(responseData['data']);
|
await _handleLoginSuccess(responseData['data']);
|
||||||
@ -93,9 +108,10 @@ class AuthService {
|
|||||||
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
return {"error": responseData['message'] ?? "Unexpected error occurred"};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Refreshes the JWT access token using the refresh token.
|
||||||
static Future<bool> refreshToken() async {
|
static Future<bool> refreshToken() async {
|
||||||
final accessToken = LocalStorage.getJwtToken();
|
final accessToken = await LocalStorage.getJwtToken();
|
||||||
final refreshToken = LocalStorage.getRefreshToken();
|
final refreshToken = await LocalStorage.getRefreshToken();
|
||||||
|
|
||||||
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
|
if ([accessToken, refreshToken].any((t) => t == null || t.isEmpty)) {
|
||||||
logSafe("Missing access or refresh token.", level: LogLevel.warning);
|
logSafe("Missing access or refresh token.", level: LogLevel.warning);
|
||||||
@ -103,24 +119,22 @@ class AuthService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final body = {"token": accessToken, "refreshToken": refreshToken};
|
final body = {"token": accessToken, "refreshToken": refreshToken};
|
||||||
final data = await _post("/auth/refresh-token", body);
|
final data = await _networkRequest(
|
||||||
if (data != null && data['success'] == true) {
|
path: "/auth/refresh-token",
|
||||||
|
method: _HttpMethod.post,
|
||||||
|
body: body,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data != null && data['success'] == true && data['data'] != null) {
|
||||||
await LocalStorage.setJwtToken(data['data']['token']);
|
await LocalStorage.setJwtToken(data['data']['token']);
|
||||||
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||||
await LocalStorage.setLoggedInUser(true);
|
await LocalStorage.setLoggedInUser(true);
|
||||||
logSafe("Token refreshed successfully.");
|
logSafe("Token refreshed successfully.");
|
||||||
|
|
||||||
// 🔹 Retry FCM token registration after token refresh
|
final newFcmToken = await LocalStorage.getFcmToken();
|
||||||
final newFcmToken = LocalStorage.getFcmToken();
|
|
||||||
if (newFcmToken?.isNotEmpty ?? false) {
|
if (newFcmToken?.isNotEmpty ?? false) {
|
||||||
final success = await registerDeviceToken(newFcmToken!);
|
await registerDeviceToken(newFcmToken!);
|
||||||
logSafe(
|
|
||||||
success
|
|
||||||
? "✅ FCM token re-registered after JWT refresh."
|
|
||||||
: "⚠️ Failed to register FCM token after JWT refresh.",
|
|
||||||
level: success ? LogLevel.info : LogLevel.warning);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
logSafe("Refresh token failed: ${data?['message']}",
|
logSafe("Refresh token failed: ${data?['message']}",
|
||||||
@ -128,35 +142,29 @@ class AuthService {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Initiates the forgot password process.
|
||||||
static Future<Map<String, String>?> forgotPassword(String email) =>
|
static Future<Map<String, String>?> forgotPassword(String email) =>
|
||||||
_wrapErrorHandling(() => _post("/auth/forgot-password", {"email": email}),
|
_wrapErrorHandling(
|
||||||
|
() => _networkRequest(
|
||||||
|
path: "/auth/forgot-password",
|
||||||
|
method: _HttpMethod.post,
|
||||||
|
body: {"email": email},
|
||||||
|
),
|
||||||
successCondition: (data) => data['success'] == true,
|
successCondition: (data) => data['success'] == true,
|
||||||
defaultError: "Failed to send reset link.");
|
defaultError: "Failed to send reset link.");
|
||||||
|
|
||||||
static Future<Map<String, String>?> requestDemo(
|
/// Generates an MPIN for the user.
|
||||||
Map<String, dynamic> demoData) =>
|
|
||||||
_wrapErrorHandling(() => _post("/market/inquiry", demoData),
|
|
||||||
successCondition: (data) => data['success'] == true,
|
|
||||||
defaultError: "Failed to submit demo request.");
|
|
||||||
|
|
||||||
static Future<List<Map<String, dynamic>>?> getIndustries() async {
|
|
||||||
final data = await _get("/market/industries");
|
|
||||||
if (data != null && data['success'] == true) {
|
|
||||||
return List<Map<String, dynamic>>.from(data['data']);
|
|
||||||
}
|
|
||||||
return null;
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<Map<String, String>?> generateMpin({
|
static Future<Map<String, String>?> generateMpin({
|
||||||
required String employeeId,
|
required String employeeId,
|
||||||
required String mpin,
|
required String mpin,
|
||||||
}) =>
|
}) =>
|
||||||
_wrapErrorHandling(
|
_wrapErrorHandling(
|
||||||
() async {
|
() async {
|
||||||
final token = LocalStorage.getJwtToken();
|
final token = await LocalStorage.getJwtToken();
|
||||||
return _post(
|
return _networkRequest(
|
||||||
"/auth/generate-mpin",
|
path: "/auth/generate-mpin",
|
||||||
{"employeeId": employeeId, "mpin": mpin},
|
method: _HttpMethod.post,
|
||||||
|
body: {"employeeId": employeeId, "mpin": mpin},
|
||||||
authToken: token,
|
authToken: token,
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -164,6 +172,7 @@ class AuthService {
|
|||||||
defaultError: "Failed to generate MPIN.",
|
defaultError: "Failed to generate MPIN.",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Verifies the MPIN for quick login.
|
||||||
static Future<Map<String, String>?> verifyMpin({
|
static Future<Map<String, String>?> verifyMpin({
|
||||||
required String mpin,
|
required String mpin,
|
||||||
required String mpinToken,
|
required String mpinToken,
|
||||||
@ -171,12 +180,15 @@ class AuthService {
|
|||||||
}) =>
|
}) =>
|
||||||
_wrapErrorHandling(
|
_wrapErrorHandling(
|
||||||
() async {
|
() async {
|
||||||
final employeeInfo = LocalStorage.getEmployeeInfo();
|
final employeeInfo = await LocalStorage.getEmployeeInfo();
|
||||||
if (employeeInfo == null) return null;
|
if (employeeInfo == null)
|
||||||
|
return null; // Fails immediately if info is missing
|
||||||
final token = await LocalStorage.getJwtToken();
|
final token = await LocalStorage.getJwtToken();
|
||||||
return _post(
|
|
||||||
"/auth/login-mpin",
|
final responseData = await _networkRequest(
|
||||||
{
|
path: "/auth/login-mpin",
|
||||||
|
method: _HttpMethod.post,
|
||||||
|
body: {
|
||||||
"employeeId": employeeInfo.id,
|
"employeeId": employeeInfo.id,
|
||||||
"mpin": mpin,
|
"mpin": mpin,
|
||||||
"mpinToken": mpinToken,
|
"mpinToken": mpinToken,
|
||||||
@ -184,21 +196,41 @@ class AuthService {
|
|||||||
},
|
},
|
||||||
authToken: token,
|
authToken: token,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Handle token updates from MPIN login success if necessary,
|
||||||
|
// though typically refresh or a separate login handles this.
|
||||||
|
if (responseData?['data'] != null) {
|
||||||
|
await _handleLoginSuccess(responseData!['data']);
|
||||||
|
}
|
||||||
|
|
||||||
|
return responseData;
|
||||||
},
|
},
|
||||||
successCondition: (data) => data['success'] == true,
|
successCondition: (data) => data['success'] == true,
|
||||||
defaultError: "MPIN verification failed.",
|
defaultError: "MPIN verification failed.",
|
||||||
);
|
);
|
||||||
|
|
||||||
|
/// Generates an OTP for login/verification.
|
||||||
static Future<Map<String, String>?> generateOtp(String email) =>
|
static Future<Map<String, String>?> generateOtp(String email) =>
|
||||||
_wrapErrorHandling(() => _post("/auth/send-otp", {"email": email}),
|
_wrapErrorHandling(
|
||||||
|
() => _networkRequest(
|
||||||
|
path: "/auth/send-otp",
|
||||||
|
method: _HttpMethod.post,
|
||||||
|
body: {"email": email},
|
||||||
|
),
|
||||||
successCondition: (data) => data['success'] == true,
|
successCondition: (data) => data['success'] == true,
|
||||||
defaultError: "Failed to generate OTP.");
|
defaultError: "Failed to generate OTP.");
|
||||||
|
|
||||||
|
/// Verifies the OTP and completes the login process.
|
||||||
static Future<Map<String, String>?> verifyOtp({
|
static Future<Map<String, String>?> verifyOtp({
|
||||||
required String email,
|
required String email,
|
||||||
required String otp,
|
required String otp,
|
||||||
}) async {
|
}) async {
|
||||||
final data = await _post("/auth/login-otp", {"email": email, "otp": otp});
|
final data = await _networkRequest(
|
||||||
|
path: "/auth/login-otp",
|
||||||
|
method: _HttpMethod.post,
|
||||||
|
body: {"email": email, "otp": otp},
|
||||||
|
);
|
||||||
|
|
||||||
if (data != null && data['data'] != null) {
|
if (data != null && data['data'] != null) {
|
||||||
await _handleLoginSuccess(data['data']);
|
await _handleLoginSuccess(data['data']);
|
||||||
return null;
|
return null;
|
||||||
@ -206,55 +238,309 @@ class AuthService {
|
|||||||
return {"error": data?['message'] ?? "OTP verification failed."};
|
return {"error": data?['message'] ?? "OTP verification failed."};
|
||||||
}
|
}
|
||||||
|
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
/* Private Utilities */
|
/* MARKET/OTHER METHODS */
|
||||||
/* -------------------------------------------------------------------------- */
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> _post(
|
/// Submits a demo request to the market endpoint.
|
||||||
String path,
|
static Future<Map<String, String>?> requestDemo(
|
||||||
Map<String, dynamic> body, {
|
Map<String, dynamic> demoData) =>
|
||||||
|
_wrapErrorHandling(
|
||||||
|
() => _networkRequest(
|
||||||
|
path: "/market/inquiry",
|
||||||
|
method: _HttpMethod.post,
|
||||||
|
body: demoData,
|
||||||
|
),
|
||||||
|
successCondition: (data) => data['success'] == true,
|
||||||
|
defaultError: "Failed to submit demo request.");
|
||||||
|
|
||||||
|
/// Fetches the list of available industries.
|
||||||
|
static Future<List<Map<String, dynamic>>?> getIndustries() async {
|
||||||
|
final data = await _networkRequest(
|
||||||
|
path: "/market/industries",
|
||||||
|
method: _HttpMethod.get,
|
||||||
|
);
|
||||||
|
if (data != null && data['success'] == true && data['data'] is List) {
|
||||||
|
return List<Map<String, dynamic>>.from(data['data']);
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* TENANT METHODS */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
static void setSelectedTenant(Tenant tenant) {
|
||||||
|
currentTenant = tenant;
|
||||||
|
}
|
||||||
|
|
||||||
|
static bool get isTenantSelected => currentTenant != null;
|
||||||
|
|
||||||
|
/// Fetches the list of tenants the user belongs to.
|
||||||
|
static Future<List<Map<String, dynamic>>?> getTenants(
|
||||||
|
{bool hasRetried = false}) async {
|
||||||
|
final token = await LocalStorage.getJwtToken();
|
||||||
|
if (token == null) {
|
||||||
|
await _handleUnauthorized();
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = await _networkRequest(
|
||||||
|
path: "/auth/get/user/tenants",
|
||||||
|
method: _HttpMethod.get,
|
||||||
|
authToken: token,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data != null && data['success'] == true && data['data'] is List) {
|
||||||
|
return List<Map<String, dynamic>>.from(data['data']);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized via refreshToken/retry logic
|
||||||
|
if (data?['statusCode'] == 401 && !hasRetried) {
|
||||||
|
final refreshed = await refreshToken();
|
||||||
|
if (refreshed) return getTenants(hasRetried: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback on all other failures
|
||||||
|
if (data != null && data['statusCode'] != 401) {
|
||||||
|
_handleApiError(data['statusCode'], data, "Fetching tenants");
|
||||||
|
} else if (data?['statusCode'] == 401 && hasRetried) {
|
||||||
|
await _handleUnauthorized();
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Selects a specific tenant, updating the JWT and refresh tokens.
|
||||||
|
static Future<bool> selectTenant(String tenantId,
|
||||||
|
{bool hasRetried = false}) async {
|
||||||
|
final token = await LocalStorage.getJwtToken();
|
||||||
|
if (token == null) {
|
||||||
|
await _handleUnauthorized();
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = await _networkRequest(
|
||||||
|
path: "/auth/select-tenant/$tenantId",
|
||||||
|
method: _HttpMethod.post,
|
||||||
|
authToken: token,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data != null && data['success'] == true && data['data'] != null) {
|
||||||
|
await LocalStorage.setJwtToken(data['data']['token']);
|
||||||
|
await LocalStorage.setRefreshToken(data['data']['refreshToken']);
|
||||||
|
logSafe("✅ Tenant selected successfully. Tokens updated.");
|
||||||
|
|
||||||
|
// Refresh project controller data
|
||||||
|
try {
|
||||||
|
final projectController = Get.find<ProjectController>();
|
||||||
|
projectController.clearProjects();
|
||||||
|
projectController.fetchProjects();
|
||||||
|
} catch (_) {
|
||||||
|
logSafe("⚠️ ProjectController not found while refreshing projects");
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-register FCM token with new tenant context
|
||||||
|
final fcmToken = await LocalStorage.getFcmToken();
|
||||||
|
if (fcmToken?.isNotEmpty ?? false) {
|
||||||
|
await registerDeviceToken(fcmToken!);
|
||||||
|
}
|
||||||
|
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized via refreshToken/retry logic
|
||||||
|
if (data?['statusCode'] == 401 && !hasRetried) {
|
||||||
|
final refreshed = await refreshToken();
|
||||||
|
if (refreshed) return selectTenant(tenantId, hasRetried: true);
|
||||||
|
await _handleUnauthorized();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fallback on all other failures
|
||||||
|
if (data != null) {
|
||||||
|
_handleApiError(data['statusCode'], data, "Selecting tenant");
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* PERMISSION/USER METHODS */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/// Fetches all user-related data (permissions, employee info, projects).
|
||||||
|
static Future<Map<String, dynamic>> fetchAllUserData(
|
||||||
|
String token, {
|
||||||
|
bool hasRetried = false,
|
||||||
|
}) async {
|
||||||
|
logSafe("Fetching user data...");
|
||||||
|
|
||||||
|
final cached = _userDataCache[token];
|
||||||
|
if (cached != null) {
|
||||||
|
logSafe("User data cache hit.");
|
||||||
|
return cached;
|
||||||
|
}
|
||||||
|
|
||||||
|
final data = await _networkRequest(
|
||||||
|
path: "/user/profile",
|
||||||
|
method: _HttpMethod.get,
|
||||||
|
authToken: token,
|
||||||
|
);
|
||||||
|
|
||||||
|
if (data != null &&
|
||||||
|
data['success'] == true &&
|
||||||
|
data['data'] is Map<String, dynamic>) {
|
||||||
|
final responseData = data['data'] as Map<String, dynamic>;
|
||||||
|
|
||||||
|
final result = {
|
||||||
|
'permissions': _parsePermissions(responseData['featurePermissions']),
|
||||||
|
'employeeInfo': await _parseEmployeeInfo(responseData['employeeInfo']),
|
||||||
|
'projects': _parseProjectsInfo(responseData['projects']),
|
||||||
|
};
|
||||||
|
|
||||||
|
_userDataCache[token] = result;
|
||||||
|
logSafe("User data fetched and decrypted successfully.");
|
||||||
|
return result;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle 401 Unauthorized via refreshToken/retry logic
|
||||||
|
if (data?['statusCode'] == 401 && !hasRetried) {
|
||||||
|
final refreshed = await refreshToken();
|
||||||
|
final newToken = await LocalStorage.getJwtToken();
|
||||||
|
if (refreshed && newToken != null) {
|
||||||
|
return fetchAllUserData(newToken, hasRetried: true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle failure and unauthorized
|
||||||
|
if (data?['statusCode'] == 401 ||
|
||||||
|
data?['statusCode'] == 403 ||
|
||||||
|
data == null) {
|
||||||
|
await _handleUnauthorized();
|
||||||
|
throw Exception('Unauthorized or Network Error. Token refresh failed.');
|
||||||
|
}
|
||||||
|
|
||||||
|
final errorMsg = data['message'] ?? 'Unknown error';
|
||||||
|
logSafe("Failed to fetch user data: $errorMsg", level: LogLevel.warning);
|
||||||
|
throw Exception('Failed to fetch user data: $errorMsg');
|
||||||
|
}
|
||||||
|
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
/* Private Utilities */
|
||||||
|
/* -------------------------------------------------------------------------- */
|
||||||
|
|
||||||
|
/// Global handler for unauthorized access, clears tokens and redirects.
|
||||||
|
static Future<void> _handleUnauthorized() async {
|
||||||
|
logSafe(
|
||||||
|
"Clearing tokens and redirecting to login due to unauthorized access.",
|
||||||
|
level: LogLevel.warning);
|
||||||
|
await LocalStorage.removeToken('jwt_token');
|
||||||
|
await LocalStorage.removeToken('refresh_token');
|
||||||
|
await LocalStorage.setLoggedInUser(false);
|
||||||
|
isLoggedIn = false;
|
||||||
|
Get.offAllNamed('/auth/login-option');
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses raw permission list into a list of UserPermission models.
|
||||||
|
static List<UserPermission> _parsePermissions(List<dynamic>? permissions) {
|
||||||
|
logSafe("Parsing user permissions...");
|
||||||
|
if (permissions == null) return [];
|
||||||
|
return permissions
|
||||||
|
.map((perm) => UserPermission.fromJson({'id': perm}))
|
||||||
|
.toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses raw employee info, stores it locally, and returns the model.
|
||||||
|
static Future<EmployeeInfo> _parseEmployeeInfo(
|
||||||
|
Map<String, dynamic>? data) async {
|
||||||
|
logSafe("Parsing employee info...");
|
||||||
|
if (data == null) throw Exception("Employee data missing");
|
||||||
|
final employeeInfo = EmployeeInfo.fromJson(data);
|
||||||
|
await LocalStorage.setEmployeeInfo(employeeInfo);
|
||||||
|
return employeeInfo;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Parses raw projects list into a list of ProjectInfo models.
|
||||||
|
static List<ProjectInfo> _parseProjectsInfo(List<dynamic>? projects) {
|
||||||
|
logSafe("Parsing projects info...");
|
||||||
|
if (projects == null) return [];
|
||||||
|
return projects.map((proj) => ProjectInfo.fromJson(proj)).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Internal utility to report API errors.
|
||||||
|
static void _handleApiError(
|
||||||
|
int statusCode, Map<String, dynamic> data, String context) {
|
||||||
|
final message = data['message'] ?? 'Unknown error';
|
||||||
|
final level = statusCode >= 500 ? LogLevel.error : LogLevel.warning;
|
||||||
|
logSafe("❌ $context failed: $message [Status: $statusCode]", level: level);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// General network request handler for both GET and POST.
|
||||||
|
static Future<Map<String, dynamic>?> _networkRequest({
|
||||||
|
required String path,
|
||||||
|
required _HttpMethod method,
|
||||||
|
Map<String, dynamic>? body,
|
||||||
String? authToken,
|
String? authToken,
|
||||||
}) async {
|
}) async {
|
||||||
|
final uri = Uri.parse("$_baseUrl$path");
|
||||||
|
final headers = {
|
||||||
|
..._defaultHeaders,
|
||||||
|
if (authToken?.isNotEmpty ?? false) 'Authorization': 'Bearer $authToken',
|
||||||
|
};
|
||||||
|
|
||||||
|
http.Response? response;
|
||||||
try {
|
try {
|
||||||
final headers = {
|
logSafe(
|
||||||
..._headers,
|
"➡️ ${method.name.toUpperCase()} $_baseUrl$path${body != null ? '\nBody: ${jsonEncode(body)}' : ''}",
|
||||||
if (authToken?.isNotEmpty ?? false)
|
level: LogLevel.info);
|
||||||
'Authorization': 'Bearer $authToken',
|
|
||||||
};
|
if (method == _HttpMethod.post) {
|
||||||
final response = await http.post(Uri.parse("$_baseUrl$path"),
|
response =
|
||||||
headers: headers, body: jsonEncode(body));
|
await http.post(uri, headers: headers, body: jsonEncode(body));
|
||||||
return {
|
} else {
|
||||||
...jsonDecode(response.body),
|
// GET
|
||||||
"statusCode": response.statusCode,
|
response = await http.get(uri, headers: headers);
|
||||||
};
|
}
|
||||||
|
|
||||||
|
if (response.body.isEmpty || response.body.trim().isEmpty) {
|
||||||
|
logSafe("❌ Empty response for $path", level: LogLevel.error);
|
||||||
|
// Special case for unauthorized response with no body (e.g., gateway issue)
|
||||||
|
if (response.statusCode == 401) {
|
||||||
|
await _handleUnauthorized();
|
||||||
|
}
|
||||||
|
return {
|
||||||
|
"statusCode": response.statusCode,
|
||||||
|
"success": false,
|
||||||
|
"message": "Empty response body"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final decrypted = decryptResponse(response.body);
|
||||||
|
|
||||||
|
if (decrypted == null) {
|
||||||
|
logSafe("❌ Response decryption failed for $path",
|
||||||
|
level: LogLevel.error);
|
||||||
|
return {
|
||||||
|
"statusCode": response.statusCode,
|
||||||
|
"success": false,
|
||||||
|
"message": "Failed to decrypt response"
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
final Map<String, dynamic> result = decrypted is Map<String, dynamic>
|
||||||
|
? decrypted
|
||||||
|
: {"data": decrypted}; // Wrap non-map responses
|
||||||
|
|
||||||
|
logSafe(
|
||||||
|
"⬅️ Response: ${jsonEncode(result)} [Status: ${response.statusCode}]",
|
||||||
|
level: LogLevel.info);
|
||||||
|
|
||||||
|
return {"statusCode": response.statusCode, ...result};
|
||||||
} catch (e, st) {
|
} catch (e, st) {
|
||||||
_handleError("$path POST error", e, st);
|
_handleError("$path ${method.name.toUpperCase()} error", e, st);
|
||||||
return null;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
static Future<Map<String, dynamic>?> _get(
|
|
||||||
String path, {
|
|
||||||
String? authToken,
|
|
||||||
}) async {
|
|
||||||
try {
|
|
||||||
final headers = {
|
|
||||||
..._headers,
|
|
||||||
if (authToken?.isNotEmpty ?? false)
|
|
||||||
'Authorization': 'Bearer $authToken',
|
|
||||||
};
|
|
||||||
final response =
|
|
||||||
await http.get(Uri.parse("$_baseUrl$path"), headers: headers);
|
|
||||||
return {
|
|
||||||
...jsonDecode(response.body),
|
|
||||||
"statusCode": response.statusCode,
|
|
||||||
};
|
|
||||||
} catch (e, st) {
|
|
||||||
_handleError("$path GET error", e, st);
|
|
||||||
return null;
|
return null;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Utility to wrap simple API calls with error-to-UI message mapping.
|
||||||
static Future<Map<String, String>?> _wrapErrorHandling(
|
static Future<Map<String, String>?> _wrapErrorHandling(
|
||||||
Future<Map<String, dynamic>?> Function() request, {
|
Future<Map<String, dynamic>?> Function() request, {
|
||||||
required bool Function(Map<String, dynamic> data) successCondition,
|
required bool Function(Map<String, dynamic> data) successCondition,
|
||||||
@ -265,13 +551,13 @@ class AuthService {
|
|||||||
return {"error": data?['message'] ?? defaultError};
|
return {"error": data?['message'] ?? defaultError};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Generic error logging helper.
|
||||||
static void _handleError(String message, Object error, StackTrace st) {
|
static void _handleError(String message, Object error, StackTrace st) {
|
||||||
logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
|
logSafe(message, level: LogLevel.error, error: error, stackTrace: st);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Common logic for storing tokens and login state upon successful authentication.
|
||||||
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
|
static Future<void> _handleLoginSuccess(Map<String, dynamic> data) async {
|
||||||
logSafe("Processing login success...");
|
|
||||||
|
|
||||||
await LocalStorage.setJwtToken(data['token']);
|
await LocalStorage.setJwtToken(data['token']);
|
||||||
await LocalStorage.setLoggedInUser(true);
|
await LocalStorage.setLoggedInUser(true);
|
||||||
|
|
||||||
@ -287,6 +573,5 @@ class AuthService {
|
|||||||
await LocalStorage.removeMpinToken();
|
await LocalStorage.removeMpinToken();
|
||||||
}
|
}
|
||||||
isLoggedIn = true;
|
isLoggedIn = true;
|
||||||
logSafe("✅ Login flow completed and controllers initialized.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
255
lib/helpers/services/http_client.dart
Normal file
255
lib/helpers/services/http_client.dart
Normal 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();
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -2,7 +2,6 @@ import 'dart:convert';
|
|||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:shared_preferences/shared_preferences.dart';
|
import 'package:shared_preferences/shared_preferences.dart';
|
||||||
|
|
||||||
import 'package:on_field_work/controller/project_controller.dart';
|
|
||||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||||
import 'package:on_field_work/helpers/services/localizations/language.dart';
|
import 'package:on_field_work/helpers/services/localizations/language.dart';
|
||||||
import 'package:on_field_work/helpers/theme/theme_customizer.dart';
|
import 'package:on_field_work/helpers/theme/theme_customizer.dart';
|
||||||
@ -139,6 +138,7 @@ class LocalStorage {
|
|||||||
print("Logout API error: $e");
|
print("Logout API error: $e");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Remove all stored values
|
||||||
await removeLoggedInUser();
|
await removeLoggedInUser();
|
||||||
await removeToken(_jwtTokenKey);
|
await removeToken(_jwtTokenKey);
|
||||||
await removeToken(_refreshTokenKey);
|
await removeToken(_refreshTokenKey);
|
||||||
@ -147,16 +147,15 @@ class LocalStorage {
|
|||||||
await removeMpinToken();
|
await removeMpinToken();
|
||||||
await removeIsMpin();
|
await removeIsMpin();
|
||||||
await removeMenus();
|
await removeMenus();
|
||||||
await removeRecentTenantId();
|
await removeRecentTenantId();
|
||||||
await preferences.remove("mpin_verified");
|
await preferences.remove("mpin_verified");
|
||||||
await preferences.remove(_languageKey);
|
await preferences.remove(_languageKey);
|
||||||
await preferences.remove(_themeCustomizerKey);
|
await preferences.remove(_themeCustomizerKey);
|
||||||
await preferences.remove('selectedProjectId');
|
await preferences.remove('selectedProjectId');
|
||||||
|
|
||||||
if (Get.isRegistered<ProjectController>()) {
|
// ❗ Clear all GetX controllers
|
||||||
Get.find<ProjectController>().clearProjects();
|
Get.deleteAll(force: true);
|
||||||
}
|
// Navigate to login
|
||||||
|
|
||||||
Get.offAllNamed('/auth/login-option');
|
Get.offAllNamed('/auth/login-option');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -25,7 +25,8 @@ int get flexColumns => MyScreenMedia.flexColumns;
|
|||||||
class MaterialRadius {
|
class MaterialRadius {
|
||||||
double xs, small, medium, large;
|
double xs, small, medium, large;
|
||||||
|
|
||||||
MaterialRadius({this.xs = 2, this.small = 4, this.medium = 6, this.large = 8});
|
MaterialRadius(
|
||||||
|
{this.xs = 2, this.small = 4, this.medium = 6, this.large = 8});
|
||||||
}
|
}
|
||||||
|
|
||||||
class ColorGroup {
|
class ColorGroup {
|
||||||
@ -41,10 +42,12 @@ class AppTheme {
|
|||||||
static Color primaryColor = Color(0xff663399);
|
static Color primaryColor = Color(0xff663399);
|
||||||
|
|
||||||
static ThemeData getThemeFromThemeMode() {
|
static ThemeData getThemeFromThemeMode() {
|
||||||
return ThemeCustomizer.instance.theme == ThemeMode.light ? lightTheme : darkTheme;
|
return ThemeCustomizer.instance.theme == ThemeMode.light
|
||||||
|
? lightTheme
|
||||||
|
: darkTheme;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// -------------------------- Light Theme -------------------------------------------- ///
|
/// -------------------------- Light Theme -------------------------------------------- ///
|
||||||
|
|
||||||
static final ThemeData lightTheme = ThemeData(
|
static final ThemeData lightTheme = ThemeData(
|
||||||
/// Brightness
|
/// Brightness
|
||||||
@ -60,14 +63,18 @@ class AppTheme {
|
|||||||
|
|
||||||
/// AppBar Theme
|
/// AppBar Theme
|
||||||
appBarTheme: AppBarTheme(
|
appBarTheme: AppBarTheme(
|
||||||
backgroundColor: Color(0xffF5F5F5), iconTheme: IconThemeData(color: Color(0xff495057)), actionsIconTheme: IconThemeData(color: Color(0xff495057))),
|
backgroundColor: Color(0xffF5F5F5),
|
||||||
|
iconTheme: IconThemeData(color: Color(0xff495057)),
|
||||||
|
actionsIconTheme: IconThemeData(color: Color(0xff495057))),
|
||||||
|
|
||||||
/// Card Theme
|
/// Card Theme
|
||||||
cardTheme: CardTheme(color: Color(0xffffffff)),
|
// FIX: Use CardThemeData
|
||||||
|
cardTheme: CardThemeData(color: Color(0xffffffff)),
|
||||||
cardColor: Color(0xffffffff),
|
cardColor: Color(0xffffffff),
|
||||||
|
|
||||||
/// Colorscheme
|
/// Colorscheme
|
||||||
colorScheme: ColorScheme.fromSeed(seedColor: Color(0xff663399), brightness: Brightness.light),
|
colorScheme: ColorScheme.fromSeed(
|
||||||
|
seedColor: Color(0xff663399), brightness: Brightness.light),
|
||||||
|
|
||||||
snackBarTheme: SnackBarThemeData(actionTextColor: Colors.white),
|
snackBarTheme: SnackBarThemeData(actionTextColor: Colors.white),
|
||||||
|
|
||||||
@ -86,10 +93,12 @@ class AppTheme {
|
|||||||
dividerColor: Color(0xffdddddd),
|
dividerColor: Color(0xffdddddd),
|
||||||
|
|
||||||
/// Bottom AppBar Theme
|
/// Bottom AppBar Theme
|
||||||
bottomAppBarTheme: BottomAppBarTheme(color: Color(0xffeeeeee), elevation: 2),
|
// FIX: Use BottomAppBarThemeData
|
||||||
|
bottomAppBarTheme:
|
||||||
|
BottomAppBarThemeData(color: Color(0xffeeeeee), elevation: 2),
|
||||||
|
|
||||||
/// Tab bar Theme
|
/// Tab bar Theme
|
||||||
tabBarTheme: TabBarTheme(
|
tabBarTheme: TabBarThemeData(
|
||||||
unselectedLabelColor: Color(0xff495057),
|
unselectedLabelColor: Color(0xff495057),
|
||||||
labelColor: AppTheme.primaryColor,
|
labelColor: AppTheme.primaryColor,
|
||||||
indicatorSize: TabBarIndicatorSize.label,
|
indicatorSize: TabBarIndicatorSize.label,
|
||||||
@ -123,8 +132,11 @@ class AppTheme {
|
|||||||
checkColor: WidgetStateProperty.all(Color(0xffffffff)),
|
checkColor: WidgetStateProperty.all(Color(0xffffffff)),
|
||||||
fillColor: WidgetStateProperty.all(AppTheme.primaryColor),
|
fillColor: WidgetStateProperty.all(AppTheme.primaryColor),
|
||||||
),
|
),
|
||||||
switchTheme:
|
switchTheme: SwitchThemeData(
|
||||||
SwitchThemeData(thumbColor: WidgetStateProperty.resolveWith((states) => states.contains(WidgetState.selected) ? AppTheme.primaryColor : Colors.white)),
|
thumbColor: WidgetStateProperty.resolveWith((states) =>
|
||||||
|
states.contains(WidgetState.selected)
|
||||||
|
? AppTheme.primaryColor
|
||||||
|
: Colors.white)),
|
||||||
|
|
||||||
/// Other Colors
|
/// Other Colors
|
||||||
splashColor: Colors.white.withAlpha(100),
|
splashColor: Colors.white.withAlpha(100),
|
||||||
@ -132,8 +144,9 @@ class AppTheme {
|
|||||||
highlightColor: Color(0xffeeeeee),
|
highlightColor: Color(0xffeeeeee),
|
||||||
);
|
);
|
||||||
|
|
||||||
/// -------------------------- Dark Theme -------------------------------------------- ///
|
/// -------------------------- Dark Theme -------------------------------------------- ///
|
||||||
static final ThemeData darkTheme = ThemeData.dark(useMaterial3: false).copyWith(
|
static final ThemeData darkTheme =
|
||||||
|
ThemeData.dark(useMaterial3: false).copyWith(
|
||||||
/// Brightness
|
/// Brightness
|
||||||
|
|
||||||
/// Scaffold and Background color
|
/// Scaffold and Background color
|
||||||
@ -146,7 +159,8 @@ class AppTheme {
|
|||||||
appBarTheme: AppBarTheme(backgroundColor: Color(0xff262729)),
|
appBarTheme: AppBarTheme(backgroundColor: Color(0xff262729)),
|
||||||
|
|
||||||
/// Card Theme
|
/// Card Theme
|
||||||
cardTheme: CardTheme(color: Color(0xff1b1b1c)),
|
// FIX: Use CardThemeData
|
||||||
|
cardTheme: CardThemeData(color: Color(0xff1b1b1c)),
|
||||||
cardColor: Color(0xff1b1b1c),
|
cardColor: Color(0xff1b1b1c),
|
||||||
|
|
||||||
/// Colorscheme
|
/// Colorscheme
|
||||||
@ -175,10 +189,13 @@ class AppTheme {
|
|||||||
foregroundColor: Colors.white),
|
foregroundColor: Colors.white),
|
||||||
|
|
||||||
/// Bottom AppBar Theme
|
/// Bottom AppBar Theme
|
||||||
bottomAppBarTheme: BottomAppBarTheme(color: Color(0xff464c52), elevation: 2),
|
// FIX: Use BottomAppBarThemeData
|
||||||
|
bottomAppBarTheme:
|
||||||
|
BottomAppBarThemeData(color: Color(0xff464c52), elevation: 2),
|
||||||
|
|
||||||
/// Tab bar Theme
|
/// Tab bar Theme
|
||||||
tabBarTheme: TabBarTheme(
|
// FIX: Use TabBarThemeData
|
||||||
|
tabBarTheme: TabBarThemeData(
|
||||||
unselectedLabelColor: Color(0xff495057),
|
unselectedLabelColor: Color(0xff495057),
|
||||||
labelColor: AppTheme.primaryColor,
|
labelColor: AppTheme.primaryColor,
|
||||||
indicatorSize: TabBarIndicatorSize.label,
|
indicatorSize: TabBarIndicatorSize.label,
|
||||||
@ -230,7 +247,8 @@ class AppStyle {
|
|||||||
containerRadius: AppStyle.containerRadius.medium,
|
containerRadius: AppStyle.containerRadius.medium,
|
||||||
cardRadius: AppStyle.cardRadius.medium,
|
cardRadius: AppStyle.cardRadius.medium,
|
||||||
buttonRadius: AppStyle.buttonRadius.medium,
|
buttonRadius: AppStyle.buttonRadius.medium,
|
||||||
defaultBreadCrumbItem: MyBreadcrumbItem(name: 'On Field Work', route: '/client/dashboard'),
|
defaultBreadCrumbItem:
|
||||||
|
MyBreadcrumbItem(name: 'OnFieldWork.com', route: '/client/dashboard'),
|
||||||
));
|
));
|
||||||
bool isMobile = true;
|
bool isMobile = true;
|
||||||
try {
|
try {
|
||||||
@ -241,12 +259,16 @@ class AppStyle {
|
|||||||
My.setFlexSpacing(isMobile ? 16 : 24);
|
My.setFlexSpacing(isMobile ? 16 : 24);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// -------------------------- Styles -------------------------------------------- ///
|
/// -------------------------- Styles -------------------------------------------- ///
|
||||||
|
|
||||||
static MaterialRadius buttonRadius = MaterialRadius(small: 2, medium: 4, large: 8);
|
static MaterialRadius buttonRadius =
|
||||||
static MaterialRadius cardRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
MaterialRadius(small: 2, medium: 4, large: 8);
|
||||||
static MaterialRadius containerRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
static MaterialRadius cardRadius =
|
||||||
static MaterialRadius imageRadius = MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||||
|
static MaterialRadius containerRadius =
|
||||||
|
MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||||
|
static MaterialRadius imageRadius =
|
||||||
|
MaterialRadius(xs: 2, small: 4, medium: 4, large: 8);
|
||||||
}
|
}
|
||||||
|
|
||||||
class AppColors {
|
class AppColors {
|
||||||
@ -262,13 +284,16 @@ class AppColors {
|
|||||||
static ColorGroup orange = ColorGroup(Color(0xffFFCEC2), Color(0xffFF3B0A));
|
static ColorGroup orange = ColorGroup(Color(0xffFFCEC2), Color(0xffFF3B0A));
|
||||||
static ColorGroup skyBlue = ColorGroup(Color(0xffC2F0FF), Color(0xff0099CC));
|
static ColorGroup skyBlue = ColorGroup(Color(0xffC2F0FF), Color(0xff0099CC));
|
||||||
static ColorGroup lavender = ColorGroup(Color(0xffEAE2F3), Color(0xff7748AD));
|
static ColorGroup lavender = ColorGroup(Color(0xffEAE2F3), Color(0xff7748AD));
|
||||||
static ColorGroup queenPink = ColorGroup(Color(0xffE8D9DC), Color(0xff804D57));
|
static ColorGroup queenPink =
|
||||||
static ColorGroup blueViolet = ColorGroup(Color(0xffC5C6E7), Color(0xff3B3E91));
|
ColorGroup(Color(0xffE8D9DC), Color(0xff804D57));
|
||||||
|
static ColorGroup blueViolet =
|
||||||
|
ColorGroup(Color(0xffC5C6E7), Color(0xff3B3E91));
|
||||||
static ColorGroup rosePink = ColorGroup(Color(0xffFCB1E0), Color(0xffEC0999));
|
static ColorGroup rosePink = ColorGroup(Color(0xffFCB1E0), Color(0xffEC0999));
|
||||||
|
|
||||||
static ColorGroup rubinRed = ColorGroup(Color(0x98f6a8bd), Color(0xffd03760));
|
static ColorGroup rubinRed = ColorGroup(Color(0x98f6a8bd), Color(0xffd03760));
|
||||||
static ColorGroup favorite = rubinRed;
|
static ColorGroup favorite = rubinRed;
|
||||||
static ColorGroup redOrange = ColorGroup(Color(0xffFFAD99), Color(0xffF53100));
|
static ColorGroup redOrange =
|
||||||
|
ColorGroup(Color(0xffFFAD99), Color(0xffF53100));
|
||||||
|
|
||||||
static Color notificationSuccessBGColor = Color(0xff117E68);
|
static Color notificationSuccessBGColor = Color(0xff117E68);
|
||||||
static Color notificationSuccessTextColor = Color(0xffffffff);
|
static Color notificationSuccessTextColor = Color(0xffffffff);
|
||||||
@ -278,7 +303,16 @@ class AppColors {
|
|||||||
static Color notificationErrorTextColor = Color(0xffFF3B0A);
|
static Color notificationErrorTextColor = Color(0xffFF3B0A);
|
||||||
static Color notificationErrorActionColor = Color(0xff006784);
|
static Color notificationErrorActionColor = Color(0xff006784);
|
||||||
|
|
||||||
static List<ColorGroup> list = [redOrange, violet, blue, green, orange, skyBlue, lavender, blueViolet];
|
static List<ColorGroup> list = [
|
||||||
|
redOrange,
|
||||||
|
violet,
|
||||||
|
blue,
|
||||||
|
green,
|
||||||
|
orange,
|
||||||
|
skyBlue,
|
||||||
|
lavender,
|
||||||
|
blueViolet
|
||||||
|
];
|
||||||
|
|
||||||
static ColorGroup get random => list[Random().nextInt(list.length)];
|
static ColorGroup get random => list[Random().nextInt(list.length)];
|
||||||
|
|
||||||
@ -287,7 +321,13 @@ class AppColors {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Color getColorByRating(int rating) {
|
static Color getColorByRating(int rating) {
|
||||||
var colors = {1: Color(0xfff0323c), 2: Color(0xcdf0323c), 3: star, 4: Color(0xcd3cd278), 5: Color(0xff3cd278)};
|
var colors = {
|
||||||
|
1: Color(0xfff0323c),
|
||||||
|
2: Color(0xcdf0323c),
|
||||||
|
3: star,
|
||||||
|
4: Color(0xcd3cd278),
|
||||||
|
5: Color(0xff3cd278)
|
||||||
|
};
|
||||||
|
|
||||||
return colors[rating] ?? colors[1]!;
|
return colors[rating] ?? colors[1]!;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -18,7 +18,12 @@ class ThemeOption {
|
|||||||
|
|
||||||
final List<ThemeOption> themeOptions = [
|
final List<ThemeOption> themeOptions = [
|
||||||
ThemeOption(
|
ThemeOption(
|
||||||
"Theme 1", Colors.red, Colors.red, Colors.red, ColorThemeType.red),
|
"Theme 1",
|
||||||
|
const Color(0xFFC92226),
|
||||||
|
const Color(0xFFC92226),
|
||||||
|
const Color(0xFFC92226),
|
||||||
|
ColorThemeType.red,
|
||||||
|
),
|
||||||
ThemeOption(
|
ThemeOption(
|
||||||
"Theme 2",
|
"Theme 2",
|
||||||
const Color(0xFF49BF3C),
|
const Color(0xFF49BF3C),
|
||||||
|
|||||||
75
lib/helpers/utils/encryption_helper.dart
Normal file
75
lib/helpers/utils/encryption_helper.dart
Normal 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;
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -5,11 +5,9 @@ import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
|||||||
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
||||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||||
|
|
||||||
class CustomAppBar extends StatefulWidget
|
class CustomAppBar extends StatefulWidget implements PreferredSizeWidget {
|
||||||
with UIMixin
|
|
||||||
implements PreferredSizeWidget {
|
|
||||||
final String title;
|
final String title;
|
||||||
final String? projectName; // If passed, show static text
|
final String? projectName;
|
||||||
final VoidCallback? onBackPressed;
|
final VoidCallback? onBackPressed;
|
||||||
final Color? backgroundColor;
|
final Color? backgroundColor;
|
||||||
|
|
||||||
@ -51,13 +49,13 @@ class _CustomAppBarState extends State<CustomAppBar> with UIMixin {
|
|||||||
return OverlayEntry(
|
return OverlayEntry(
|
||||||
builder: (context) => GestureDetector(
|
builder: (context) => GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
_toggleDropdown();
|
_toggleDropdown();
|
||||||
},
|
},
|
||||||
behavior: HitTestBehavior.translucent,
|
behavior: HitTestBehavior.translucent,
|
||||||
child: Stack(
|
child: Stack(
|
||||||
children: [
|
children: [
|
||||||
Positioned(
|
Positioned(
|
||||||
left: offset.dx + 16,
|
left: offset.dx + 16,
|
||||||
top: offset.dy + size.height,
|
top: offset.dy + size.height,
|
||||||
width: size.width - 32,
|
width: size.width - 32,
|
||||||
child: Material(
|
child: Material(
|
||||||
|
|||||||
@ -28,9 +28,7 @@ class CollectionsHealthWidget extends StatelessWidget {
|
|||||||
return Container(
|
return Container(
|
||||||
decoration: _boxDecoration(),
|
decoration: _boxDecoration(),
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: const EdgeInsets.all(16.0),
|
||||||
child: Center(
|
child: const _EmptyDataWidget(), // <-- Use the new empty widget here
|
||||||
child: MyText.bodyMedium('No collection overview data available.'),
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -287,6 +285,71 @@ class CollectionsHealthWidget extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// NEW EMPTY DATA WIDGET FOR CollectionsHealthWidget
|
||||||
|
// =====================================================================
|
||||||
|
class _EmptyDataWidget extends StatelessWidget {
|
||||||
|
const _EmptyDataWidget({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
// This height is set to resemble the expected height of the chart/metrics content
|
||||||
|
const double containerHeight = 220;
|
||||||
|
const double iconSize = 48;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header Section
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.bodyMedium('Collections Health Overview',
|
||||||
|
fontWeight: 700),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
MyText.bodySmall('View your collection health data.',
|
||||||
|
color: Colors.grey),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
|
||||||
|
// Empty Content Area
|
||||||
|
SizedBox(
|
||||||
|
height: containerHeight,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
'No collection overview data available.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Please check your data source or filters.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
|
// CUSTOM PLACEHOLDER WIDGETS (Gauge + Bar/Area + Aging Bars)
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
|
|||||||
@ -12,19 +12,39 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final DashboardController controller = Get.find();
|
final DashboardController controller = Get.find();
|
||||||
|
|
||||||
|
// Define the common box decoration for the main card structure
|
||||||
|
final BoxDecoration cardDecoration = BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.black.withOpacity(0.08),
|
||||||
|
blurRadius: 15,
|
||||||
|
offset: const Offset(0, 5),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
// Use Obx to reactively listen to data changes
|
// Use Obx to reactively listen to data changes
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
final data = controller.purchaseInvoiceOverviewData.value;
|
final data = controller.purchaseInvoiceOverviewData.value;
|
||||||
|
|
||||||
// Show loading state while API call is in progress
|
// Show loading state while API call is in progress
|
||||||
if (controller.isPurchaseInvoiceLoading.value) {
|
if (controller.isPurchaseInvoiceLoading.value) {
|
||||||
return SkeletonLoaders.purchaseInvoiceDashboardSkeleton();
|
return Container(
|
||||||
|
decoration: cardDecoration, // Apply decoration to loading state
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: SkeletonLoaders.purchaseInvoiceDashboardSkeleton(),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Show empty state if no data
|
// Show empty state if no data
|
||||||
if (data == null || data.totalInvoices == 0) {
|
if (data == null || data.totalInvoices == 0) {
|
||||||
return Center(
|
return Container(
|
||||||
child: MyText.bodySmall('No purchase invoices found.'),
|
decoration: cardDecoration, // Apply decoration to empty state
|
||||||
|
padding: const EdgeInsets.all(16.0),
|
||||||
|
child: const _EmptyDataWidget(), // <-- Use the new empty widget
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -42,27 +62,17 @@ class CompactPurchaseInvoiceDashboard extends StatelessWidget {
|
|||||||
|
|
||||||
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
|
final metrics = PurchaseInvoiceMetricsCalculator().calculate(invoices);
|
||||||
|
|
||||||
return _buildDashboard(metrics);
|
return _buildDashboard(metrics, cardDecoration);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildDashboard(PurchaseInvoiceMetrics metrics) {
|
Widget _buildDashboard(PurchaseInvoiceMetrics metrics, BoxDecoration decoration) {
|
||||||
const double spacing = 16.0;
|
const double spacing = 16.0;
|
||||||
const double smallSpacing = 8.0;
|
const double smallSpacing = 8.0;
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(spacing),
|
padding: const EdgeInsets.all(spacing),
|
||||||
decoration: BoxDecoration(
|
decoration: decoration, // Use the passed decoration
|
||||||
color: Colors.white,
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black.withOpacity(0.08),
|
|
||||||
blurRadius: 15,
|
|
||||||
offset: const Offset(0, 5),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||||
mainAxisSize: MainAxisSize.min,
|
mainAxisSize: MainAxisSize.min,
|
||||||
@ -319,6 +329,56 @@ Color getColorForStatus(String status) {
|
|||||||
/// REDESIGNED INTERNAL UI WIDGETS
|
/// REDESIGNED INTERNAL UI WIDGETS
|
||||||
/// =======================
|
/// =======================
|
||||||
|
|
||||||
|
// NEW WIDGET: Empty Data Card
|
||||||
|
class _EmptyDataWidget extends StatelessWidget {
|
||||||
|
const _EmptyDataWidget({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
const double containerHeight = 220;
|
||||||
|
const double iconSize = 48;
|
||||||
|
const double spacing = 16.0;
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Header Section
|
||||||
|
const _DashboardHeader(),
|
||||||
|
const SizedBox(height: spacing),
|
||||||
|
|
||||||
|
// Empty Content Area
|
||||||
|
SizedBox(
|
||||||
|
height: containerHeight,
|
||||||
|
child: Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.info_outline,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
size: iconSize,
|
||||||
|
),
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
MyText.bodyMedium(
|
||||||
|
'No purchase invoice data available.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
'Please check your data source or filters.',
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
class _SectionTitle extends StatelessWidget {
|
class _SectionTitle extends StatelessWidget {
|
||||||
final String title;
|
final String title;
|
||||||
|
|
||||||
@ -714,4 +774,4 @@ class _ProjectBreakdown extends StatelessWidget {
|
|||||||
}).toList(),
|
}).toList(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -22,7 +22,7 @@ class SkeletonLoaders {
|
|||||||
height: 16,
|
height: 16,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@ -35,19 +35,149 @@ class SkeletonLoaders {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Widget serviceProjectListSkeletonLoader() {
|
||||||
|
// --- Start: Configuration to match live UI ---
|
||||||
|
// Live UI uses ListView.separated with:
|
||||||
|
// - padding: MySpacing.only(left: 8, right: 8, top: 4, bottom: 120)
|
||||||
|
// - separatorBuilder: MySpacing.height(12)
|
||||||
|
// - _buildProjectCard uses Card(margin: const EdgeInsets.symmetric(horizontal: 6, vertical: 4))
|
||||||
|
|
||||||
|
// To combine:
|
||||||
|
// Horizontal padding: 8 (ListView) + 6 (Card margin) = 14 on each side.
|
||||||
|
// Top/Bottom separation: 4 (ListView padding) + 4 (Card margin) = 8
|
||||||
|
// Separator space: 4 (Card margin) + 12 (Separator) + 4 (Card margin) = 20 total space between cards.
|
||||||
|
|
||||||
|
// New ListView.separated padding to compensate for inner Card margins
|
||||||
|
const EdgeInsets listPadding =
|
||||||
|
const EdgeInsets.fromLTRB(14, 8, 14, 120 + 4); // 8(L/R) + 6(Card L/R Margin) = 14
|
||||||
|
// New separator to match the 12 + 4 * 2 = 20 gap.
|
||||||
|
const Widget cardSeparator = const SizedBox(height: 12);
|
||||||
|
const EdgeInsets cardMargin = EdgeInsets.zero; // Margin is now controlled by the ListView.separated padding
|
||||||
|
|
||||||
|
// Internal Card padding matches the live card
|
||||||
|
const EdgeInsets cardInnerPadding =
|
||||||
|
const EdgeInsets.symmetric(horizontal: 18, vertical: 14);
|
||||||
|
// --- End: Configuration to match live UI ---
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
padding: listPadding, // Use calculated padding
|
||||||
|
physics:
|
||||||
|
const NeverScrollableScrollPhysics(),
|
||||||
|
itemCount: 4,
|
||||||
|
separatorBuilder: (_, __) => cardSeparator, // Use calculated separator
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
return Card(
|
||||||
|
shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(5)),
|
||||||
|
margin: cardMargin, // Set margin to zero, handled by ListView padding
|
||||||
|
shadowColor: Colors.indigo.withOpacity(0.10),
|
||||||
|
color: Colors.white,
|
||||||
|
child: ShimmerEffect(
|
||||||
|
child: Padding(
|
||||||
|
padding: cardInnerPadding, // Use live card's inner padding
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// 1. Title and Status Row
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
|
children: [
|
||||||
|
// Project Name Placeholder
|
||||||
|
Container(
|
||||||
|
height: 18, // Matches MyText.titleMedium height approx
|
||||||
|
width: 150,
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
// Status Chip Placeholder
|
||||||
|
Container(
|
||||||
|
height: 18, // Matches status chip height approx
|
||||||
|
width: 60,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
// MySpacing.height(10) in live UI is the key spacing here
|
||||||
|
// Note: The live UI has MySpacing.height(4) after the title
|
||||||
|
// and then MySpacing.height(10) before the first detail row,
|
||||||
|
// so the total space is 4 + 10 = 14.
|
||||||
|
MySpacing.height(14),
|
||||||
|
|
||||||
|
// 2. Detail Rows (Date, Client, Contact)
|
||||||
|
// Assigned Date Row
|
||||||
|
_buildDetailRowSkeleton(
|
||||||
|
width: 200, iconColor: Colors.teal.shade300),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Client Row
|
||||||
|
_buildDetailRowSkeleton(
|
||||||
|
width: 240, iconColor: Colors.indigo.shade300),
|
||||||
|
MySpacing.height(8),
|
||||||
|
|
||||||
|
// Contact Row
|
||||||
|
_buildDetailRowSkeleton(
|
||||||
|
width: 220, iconColor: Colors.green.shade300),
|
||||||
|
MySpacing.height(12), // MySpacing.height(12) before Wrap
|
||||||
|
|
||||||
|
// 3. Service Chips Wrap
|
||||||
|
Wrap(
|
||||||
|
spacing: 6,
|
||||||
|
runSpacing: 4,
|
||||||
|
children: List.generate(
|
||||||
|
3,
|
||||||
|
(chipIndex) => Container(
|
||||||
|
height: 20,
|
||||||
|
width:
|
||||||
|
70 + (chipIndex * 10).toDouble(), // Varied widths
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors
|
||||||
|
.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Helper to build a skeleton row for details
|
||||||
|
static Widget _buildDetailRowSkeleton({
|
||||||
|
required double width,
|
||||||
|
required Color iconColor,
|
||||||
|
}) {
|
||||||
|
return Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Icon Placeholder (size 18 matches live UI)
|
||||||
|
Icon(Icons.circle, size: 18, color: iconColor),
|
||||||
|
MySpacing.width(8),
|
||||||
|
// Text Placeholder (height 13 approx for font size 13)
|
||||||
|
Container(
|
||||||
|
height: 14,
|
||||||
|
width: width,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Widget attendanceQuickCardSkeleton() {
|
static Widget attendanceQuickCardSkeleton() {
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.circular(5),
|
||||||
gradient: LinearGradient(
|
// ... gradient color setup (using grey for shimmer)
|
||||||
colors: [
|
|
||||||
Colors.grey.shade300.withOpacity(0.3),
|
|
||||||
Colors.grey.shade300.withOpacity(0.6),
|
|
||||||
],
|
|
||||||
begin: Alignment.topLeft,
|
|
||||||
end: Alignment.bottomRight,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: ShimmerEffect(
|
child: ShimmerEffect(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -56,78 +186,67 @@ class SkeletonLoaders {
|
|||||||
// Row with avatar and texts
|
// Row with avatar and texts
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
// Avatar
|
// Avatar (Size 30)
|
||||||
Container(
|
Container(
|
||||||
width: 30,
|
width: 30,
|
||||||
height: 30,
|
height: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade400,
|
color: Colors.grey.shade400, shape: BoxShape.circle)),
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
MySpacing.width(10),
|
MySpacing.width(10),
|
||||||
// Name + designation
|
// Name + designation (Approximate heights for MyText.titleSmall and MyText.labelSmall)
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
height: 12,
|
height: 12, width: 100, color: Colors.grey.shade400),
|
||||||
width: 100,
|
MySpacing.height(
|
||||||
color: Colors.grey.shade400,
|
4), // Reduced from 6, guessing labelSmall is shorter
|
||||||
),
|
|
||||||
MySpacing.height(6),
|
|
||||||
Container(
|
Container(
|
||||||
height: 10,
|
height: 10, width: 70, color: Colors.grey.shade400),
|
||||||
width: 70,
|
|
||||||
color: Colors.grey.shade400,
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// Status
|
// Status (MyText.bodySmall, height approx 12-14)
|
||||||
Container(
|
Container(
|
||||||
height: 12,
|
height: 14,
|
||||||
width: 60,
|
width: 80,
|
||||||
color: Colors.grey.shade400,
|
color: Colors
|
||||||
),
|
.grey.shade400), // Adjusted width and height slightly
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Description
|
// Description (2 lines of Text, font size 13)
|
||||||
Container(
|
Container(
|
||||||
height: 10,
|
height: 14,
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: Colors.grey.shade400,
|
color: Colors.grey
|
||||||
),
|
.shade400), // Height for one line of text size 13 + padding
|
||||||
MySpacing.height(6),
|
MySpacing.height(6),
|
||||||
Container(
|
Container(
|
||||||
height: 10,
|
height: 14,
|
||||||
width: double.infinity,
|
width: double.infinity * 0.7,
|
||||||
color: Colors.grey.shade400,
|
color: Colors.grey.shade400), // Shorter second line
|
||||||
),
|
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
// Action buttons
|
// Action buttons (Row at the end)
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
|
// Check In/Out Button (Approx height 28)
|
||||||
Container(
|
Container(
|
||||||
height: 28,
|
height: 32,
|
||||||
width: 80,
|
width: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade400,
|
color: Colors.grey.shade400,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius:
|
||||||
),
|
BorderRadius.circular(5))), // Larger button size
|
||||||
),
|
|
||||||
MySpacing.width(8),
|
MySpacing.width(8),
|
||||||
|
// Log View Button (Icon Button, approx size 28-32)
|
||||||
Container(
|
Container(
|
||||||
height: 28,
|
height: 32,
|
||||||
width: 28,
|
width: 32,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade400,
|
color: Colors.grey.shade400, shape: BoxShape.circle)),
|
||||||
shape: BoxShape.circle,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -139,46 +258,148 @@ class SkeletonLoaders {
|
|||||||
static Widget dashboardCardsSkeleton({double? maxWidth}) {
|
static Widget dashboardCardsSkeleton({double? maxWidth}) {
|
||||||
return LayoutBuilder(builder: (context, constraints) {
|
return LayoutBuilder(builder: (context, constraints) {
|
||||||
double width = maxWidth ?? constraints.maxWidth;
|
double width = maxWidth ?? constraints.maxWidth;
|
||||||
int crossAxisCount = (width ~/ 80).clamp(2, 4);
|
double crossAxisSpacing = 15;
|
||||||
double cardWidth = (width - (crossAxisCount - 1) * 6) / crossAxisCount;
|
int crossAxisCount = 3;
|
||||||
|
|
||||||
return Wrap(
|
// Calculation remains the same: screen_width - (spacing * (count - 1)) / count
|
||||||
spacing: 6,
|
double totalHorizontalSpace =
|
||||||
runSpacing: 6,
|
width - (crossAxisSpacing * (crossAxisCount - 1));
|
||||||
children: List.generate(6, (index) {
|
double cardWidth = totalHorizontalSpace / crossAxisCount;
|
||||||
return MyCard.bordered(
|
|
||||||
width: cardWidth,
|
// Dynamic height calculation: width / 1.8 (e.g., 92.0 / 1.8 = 51.11, not 46.7)
|
||||||
height: 60,
|
// Rerunning the calculation based on the constraint h=46.7 given in the error:
|
||||||
paddingAll: 4,
|
// If cardWidth = 92.0, the aspect ratio must be different, or the parent widget
|
||||||
borderRadiusAll: 5,
|
// is forcing a smaller height. To fix the overflow, we must assume the target
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
// height is fixed by the aspect ratio and reduce the inner content size.
|
||||||
child: ShimmerEffect(
|
double cardHeight = cardWidth / 1.8;
|
||||||
child: Column(
|
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
// Inner available vertical space (cardHeight - 2 * paddingAll):
|
||||||
children: [
|
// If cardHeight is 51.11, inner space is 51.11 - 8 = 43.11.
|
||||||
Container(
|
// If cardHeight is 46.7 (as per error constraint), inner space is 46.7 - 8 = 38.7.
|
||||||
width: 16,
|
|
||||||
height: 16,
|
return Column(
|
||||||
decoration: BoxDecoration(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
color: Colors.grey.shade300,
|
children: [
|
||||||
borderRadius: BorderRadius.circular(4),
|
// Skeleton for the "Modules" title (fontSize 16, fontWeight 700)
|
||||||
),
|
Container(
|
||||||
),
|
margin: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
MySpacing.height(4),
|
height: 18,
|
||||||
Container(
|
width: 80,
|
||||||
width: cardWidth * 0.5,
|
color: Colors.grey.shade300),
|
||||||
height: 10,
|
GridView.builder(
|
||||||
color: Colors.grey.shade300,
|
shrinkWrap: true,
|
||||||
),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
],
|
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
|
||||||
),
|
gridDelegate: SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
|
crossAxisCount: crossAxisCount,
|
||||||
|
crossAxisSpacing: crossAxisSpacing,
|
||||||
|
mainAxisSpacing: 8,
|
||||||
|
childAspectRatio: 1.8,
|
||||||
),
|
),
|
||||||
);
|
itemCount: 6,
|
||||||
}),
|
itemBuilder: (context, index) {
|
||||||
|
return MyCard.bordered(
|
||||||
|
width: cardWidth,
|
||||||
|
height: cardHeight,
|
||||||
|
paddingAll: 4,
|
||||||
|
borderRadiusAll: 5,
|
||||||
|
border: Border.all(color: Colors.grey.withOpacity(0.15)),
|
||||||
|
child: ShimmerEffect(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
// Icon placeholder: Reduced size to 16
|
||||||
|
Container(
|
||||||
|
width: 16,
|
||||||
|
height: 16, // Reduced from 20
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(4), // Reduced spacing from 6
|
||||||
|
// Text placeholder 1: Reduced height to 8
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Container(
|
||||||
|
width: cardWidth * 0.7,
|
||||||
|
height: 8, // Reduced from 10
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
MySpacing.height(2), // Reduced spacing from 4
|
||||||
|
// Text placeholder 2: Reduced height to 8
|
||||||
|
Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
|
child: Container(
|
||||||
|
width: cardWidth * 0.5,
|
||||||
|
height: 8, // Reduced from 10
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
// Total inner height is now 16 + 4 + 8 + 2 + 8 = 38 pixels.
|
||||||
|
// This will fit safely within the calculated or constrained height.
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static Widget projectSelectorSkeleton() {
|
||||||
|
return Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// Section Title Skeleton
|
||||||
|
Container(
|
||||||
|
margin: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
|
height: 18, // For _sectionTitle
|
||||||
|
width: 80,
|
||||||
|
color: Colors.grey.shade300,
|
||||||
|
),
|
||||||
|
// Selector Card Skeleton
|
||||||
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 14, vertical: 14),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
border:
|
||||||
|
Border.all(color: Colors.grey.shade300), // Placeholder border
|
||||||
|
boxShadow: [
|
||||||
|
BoxShadow(
|
||||||
|
color: Colors.grey.withOpacity(.04),
|
||||||
|
blurRadius: 6,
|
||||||
|
offset: const Offset(0, 2),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
child: ShimmerEffect(
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
// Icon placeholder
|
||||||
|
Container(width: 20, height: 20, color: Colors.grey.shade300),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
// Text placeholder
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
|
height: 16,
|
||||||
|
width: double.infinity,
|
||||||
|
color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
// Arrow icon placeholder
|
||||||
|
Container(width: 26, height: 26, color: Colors.grey.shade300),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
static Widget paymentRequestListSkeletonLoader() {
|
static Widget paymentRequestListSkeletonLoader() {
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
padding: const EdgeInsets.fromLTRB(12, 12, 12, 80),
|
||||||
@ -198,7 +419,7 @@ class SkeletonLoaders {
|
|||||||
width: 120,
|
width: 120,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@ -211,7 +432,7 @@ class SkeletonLoaders {
|
|||||||
width: 50,
|
width: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 8),
|
const SizedBox(width: 8),
|
||||||
@ -220,7 +441,7 @@ class SkeletonLoaders {
|
|||||||
height: 12,
|
height: 12,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -239,7 +460,7 @@ class SkeletonLoaders {
|
|||||||
width: 50,
|
width: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade200,
|
color: Colors.grey.shade200,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
@ -248,7 +469,7 @@ class SkeletonLoaders {
|
|||||||
width: 80,
|
width: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -260,7 +481,7 @@ class SkeletonLoaders {
|
|||||||
width: 60,
|
width: 60,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -281,7 +502,7 @@ class SkeletonLoaders {
|
|||||||
constraints: const BoxConstraints(maxWidth: 520),
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
child: MyCard.bordered(
|
child: MyCard.bordered(
|
||||||
paddingAll: 16,
|
paddingAll: 16,
|
||||||
borderRadiusAll: 8,
|
borderRadiusAll: 5,
|
||||||
shadow: MyShadow(elevation: 3),
|
shadow: MyShadow(elevation: 3),
|
||||||
child: ShimmerEffect(
|
child: ShimmerEffect(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -345,7 +566,7 @@ class SkeletonLoaders {
|
|||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
)),
|
)),
|
||||||
@ -414,7 +635,7 @@ class SkeletonLoaders {
|
|||||||
children: [
|
children: [
|
||||||
// Header skeleton (avatar + name + role)
|
// Header skeleton (avatar + name + role)
|
||||||
MyCard(
|
MyCard(
|
||||||
borderRadiusAll: 8,
|
borderRadiusAll: 5,
|
||||||
paddingAll: 16,
|
paddingAll: 16,
|
||||||
margin: MySpacing.bottom(16),
|
margin: MySpacing.bottom(16),
|
||||||
shadow: MyShadow(elevation: 2),
|
shadow: MyShadow(elevation: 2),
|
||||||
@ -465,7 +686,7 @@ class SkeletonLoaders {
|
|||||||
(_) => Column(
|
(_) => Column(
|
||||||
children: [
|
children: [
|
||||||
MyCard(
|
MyCard(
|
||||||
borderRadiusAll: 8,
|
borderRadiusAll: 5,
|
||||||
paddingAll: 16,
|
paddingAll: 16,
|
||||||
margin: MySpacing.bottom(16),
|
margin: MySpacing.bottom(16),
|
||||||
shadow: MyShadow(elevation: 2),
|
shadow: MyShadow(elevation: 2),
|
||||||
@ -552,7 +773,7 @@ class SkeletonLoaders {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: List.generate(3, (floorIndex) {
|
children: List.generate(3, (floorIndex) {
|
||||||
return MyCard(
|
return MyCard(
|
||||||
borderRadiusAll: 8,
|
borderRadiusAll: 5,
|
||||||
paddingAll: 5,
|
paddingAll: 5,
|
||||||
margin: MySpacing.bottom(10),
|
margin: MySpacing.bottom(10),
|
||||||
shadow: MyShadow(elevation: 1.5),
|
shadow: MyShadow(elevation: 1.5),
|
||||||
@ -566,7 +787,7 @@ class SkeletonLoaders {
|
|||||||
width: 160,
|
width: 160,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.height(10),
|
MySpacing.height(10),
|
||||||
@ -588,7 +809,7 @@ class SkeletonLoaders {
|
|||||||
width: 120,
|
width: 120,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
@ -617,7 +838,7 @@ class SkeletonLoaders {
|
|||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius:
|
borderRadius:
|
||||||
BorderRadius.circular(4),
|
BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -642,7 +863,7 @@ class SkeletonLoaders {
|
|||||||
static Widget chartSkeletonLoader() {
|
static Widget chartSkeletonLoader() {
|
||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
paddingAll: 16,
|
paddingAll: 16,
|
||||||
borderRadiusAll: 12,
|
borderRadiusAll: 5,
|
||||||
shadow: MyShadow(
|
shadow: MyShadow(
|
||||||
elevation: 1.5,
|
elevation: 1.5,
|
||||||
position: MyShadowPosition.bottom,
|
position: MyShadowPosition.bottom,
|
||||||
@ -657,7 +878,7 @@ class SkeletonLoaders {
|
|||||||
width: 180,
|
width: 180,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@ -686,7 +907,7 @@ class SkeletonLoaders {
|
|||||||
height: 14,
|
height: 14,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@ -704,7 +925,7 @@ class SkeletonLoaders {
|
|||||||
width: 90,
|
width: 90,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -735,7 +956,7 @@ class SkeletonLoaders {
|
|||||||
width: 160,
|
width: 160,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
@ -767,7 +988,7 @@ class SkeletonLoaders {
|
|||||||
width: 100,
|
width: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
const SizedBox(height: 6),
|
||||||
@ -776,7 +997,7 @@ class SkeletonLoaders {
|
|||||||
width: 60,
|
width: 60,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -789,7 +1010,7 @@ class SkeletonLoaders {
|
|||||||
width: 30,
|
width: 30,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 6),
|
const SizedBox(width: 6),
|
||||||
@ -816,7 +1037,7 @@ class SkeletonLoaders {
|
|||||||
width: 120,
|
width: 120,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 4),
|
const SizedBox(height: 4),
|
||||||
@ -825,7 +1046,7 @@ class SkeletonLoaders {
|
|||||||
width: 140,
|
width: 140,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -835,7 +1056,7 @@ class SkeletonLoaders {
|
|||||||
width: 80,
|
width: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(4),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -847,8 +1068,10 @@ class SkeletonLoaders {
|
|||||||
}
|
}
|
||||||
|
|
||||||
static Widget documentSkeletonLoader() {
|
static Widget documentSkeletonLoader() {
|
||||||
return Column(
|
return ListView.builder(
|
||||||
children: List.generate(5, (index) {
|
padding: const EdgeInsets.symmetric(vertical: 0, horizontal: 0),
|
||||||
|
itemCount: 5,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
@ -861,7 +1084,7 @@ class SkeletonLoaders {
|
|||||||
width: 80,
|
width: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -873,7 +1096,7 @@ class SkeletonLoaders {
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.05),
|
color: Colors.black.withOpacity(0.05),
|
||||||
@ -891,7 +1114,7 @@ class SkeletonLoaders {
|
|||||||
padding: const EdgeInsets.all(10),
|
padding: const EdgeInsets.all(10),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
child: const Icon(Icons.description,
|
child: const Icon(Icons.description,
|
||||||
color: Colors.transparent), // invisible icon
|
color: Colors.transparent), // invisible icon
|
||||||
@ -939,7 +1162,7 @@ class SkeletonLoaders {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}),
|
},
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -955,7 +1178,7 @@ class SkeletonLoaders {
|
|||||||
padding: const EdgeInsets.all(20),
|
padding: const EdgeInsets.all(20),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(10),
|
borderRadius: BorderRadius.circular(5),
|
||||||
boxShadow: [
|
boxShadow: [
|
||||||
BoxShadow(
|
BoxShadow(
|
||||||
color: Colors.black.withOpacity(0.06),
|
color: Colors.black.withOpacity(0.06),
|
||||||
@ -1012,7 +1235,7 @@ class SkeletonLoaders {
|
|||||||
width: 60,
|
width: 60,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(12),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}),
|
}),
|
||||||
@ -1066,7 +1289,7 @@ class SkeletonLoaders {
|
|||||||
height: 40,
|
height: 40,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
@ -1112,7 +1335,7 @@ class SkeletonLoaders {
|
|||||||
return Column(
|
return Column(
|
||||||
children: List.generate(4, (index) {
|
children: List.generate(4, (index) {
|
||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
borderRadiusAll: 12,
|
borderRadiusAll: 5,
|
||||||
paddingAll: 10,
|
paddingAll: 10,
|
||||||
margin: MySpacing.bottom(12),
|
margin: MySpacing.bottom(12),
|
||||||
shadow: MyShadow(elevation: 3),
|
shadow: MyShadow(elevation: 3),
|
||||||
@ -1184,7 +1407,7 @@ class SkeletonLoaders {
|
|||||||
|
|
||||||
static Widget employeeListCollapsedSkeletonLoader() {
|
static Widget employeeListCollapsedSkeletonLoader() {
|
||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
borderRadiusAll: 4,
|
borderRadiusAll: 5,
|
||||||
paddingAll: 8,
|
paddingAll: 8,
|
||||||
child: ShimmerEffect(
|
child: ShimmerEffect(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -1256,7 +1479,7 @@ class SkeletonLoaders {
|
|||||||
|
|
||||||
static Widget dailyProgressReportSkeletonLoader() {
|
static Widget dailyProgressReportSkeletonLoader() {
|
||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
borderRadiusAll: 4,
|
borderRadiusAll: 5,
|
||||||
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
border: Border.all(color: Colors.grey.withOpacity(0.2)),
|
||||||
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
|
shadow: MyShadow(elevation: 1, position: MyShadowPosition.bottom),
|
||||||
paddingAll: 8,
|
paddingAll: 8,
|
||||||
@ -1291,7 +1514,7 @@ class SkeletonLoaders {
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: List.generate(3, (index) {
|
children: List.generate(3, (index) {
|
||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
borderRadiusAll: 12,
|
borderRadiusAll: 5,
|
||||||
paddingAll: 16,
|
paddingAll: 16,
|
||||||
margin: MySpacing.bottom(12),
|
margin: MySpacing.bottom(12),
|
||||||
shadow: MyShadow(elevation: 3),
|
shadow: MyShadow(elevation: 3),
|
||||||
@ -1350,7 +1573,7 @@ class SkeletonLoaders {
|
|||||||
width: 120,
|
width: 120,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Container(
|
Container(
|
||||||
@ -1358,7 +1581,7 @@ class SkeletonLoaders {
|
|||||||
width: 80,
|
width: 80,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -1372,7 +1595,7 @@ class SkeletonLoaders {
|
|||||||
width: 100,
|
width: 100,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const Spacer(),
|
||||||
@ -1381,7 +1604,7 @@ class SkeletonLoaders {
|
|||||||
width: 50,
|
width: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300,
|
||||||
borderRadius: BorderRadius.circular(6),
|
borderRadius: BorderRadius.circular(5),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
@ -1397,7 +1620,7 @@ class SkeletonLoaders {
|
|||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
margin: MySpacing.only(bottom: 12),
|
margin: MySpacing.only(bottom: 12),
|
||||||
paddingAll: 12,
|
paddingAll: 12,
|
||||||
borderRadiusAll: 12,
|
borderRadiusAll: 5,
|
||||||
shadow: MyShadow(
|
shadow: MyShadow(
|
||||||
elevation: 1.5,
|
elevation: 1.5,
|
||||||
position: MyShadowPosition.bottom,
|
position: MyShadowPosition.bottom,
|
||||||
@ -1480,9 +1703,8 @@ class SkeletonLoaders {
|
|||||||
return MyCard.bordered(
|
return MyCard.bordered(
|
||||||
margin: MySpacing.only(bottom: 12),
|
margin: MySpacing.only(bottom: 12),
|
||||||
paddingAll: 16,
|
paddingAll: 16,
|
||||||
borderRadiusAll: 16,
|
borderRadiusAll: 5,
|
||||||
shadow: MyShadow(
|
shadow: MyShadow(
|
||||||
elevation: 1.5,
|
|
||||||
position: MyShadowPosition.bottom,
|
position: MyShadowPosition.bottom,
|
||||||
),
|
),
|
||||||
child: ShimmerEffect(
|
child: ShimmerEffect(
|
||||||
@ -1636,7 +1858,7 @@ class SkeletonLoaders {
|
|||||||
|
|
||||||
// Aging Stacked Bar Placeholder
|
// Aging Stacked Bar Placeholder
|
||||||
ClipRRect(
|
ClipRRect(
|
||||||
borderRadius: BorderRadius.circular(8),
|
borderRadius: BorderRadius.circular(5),
|
||||||
child: Row(
|
child: Row(
|
||||||
children: List.generate(
|
children: List.generate(
|
||||||
4,
|
4,
|
||||||
@ -1845,41 +2067,28 @@ class SkeletonLoaders {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 16),
|
const SizedBox(width: 16),
|
||||||
// Legend/Details Placeholder
|
// Legend/Details Placeholder
|
||||||
Expanded(
|
// Aging Legend Placeholders
|
||||||
child: Column(
|
Wrap(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
spacing: 12,
|
||||||
children: List.generate(
|
runSpacing: 8,
|
||||||
3,
|
children: List.generate(
|
||||||
(index) => Padding(
|
4,
|
||||||
padding: const EdgeInsets.only(bottom: 8.0),
|
(index) => Row(
|
||||||
child: Row(
|
mainAxisSize: MainAxisSize.min,
|
||||||
mainAxisAlignment:
|
children: [
|
||||||
MainAxisAlignment.spaceBetween,
|
Container(
|
||||||
children: [
|
width: 10,
|
||||||
Row(
|
height: 10,
|
||||||
children: [
|
decoration: BoxDecoration(
|
||||||
Container(
|
color: Colors.grey.shade300,
|
||||||
width: 8,
|
shape: BoxShape.circle)),
|
||||||
height: 8,
|
const SizedBox(width: 6),
|
||||||
margin:
|
Container(
|
||||||
const EdgeInsets.only(right: 8),
|
height: 12,
|
||||||
decoration: BoxDecoration(
|
width: 115, // Reduced from 120
|
||||||
color: Colors.grey.shade300,
|
color: Colors.grey.shade300),
|
||||||
shape: BoxShape.circle)),
|
],
|
||||||
Container(
|
)),
|
||||||
height: 12,
|
|
||||||
width: 80,
|
|
||||||
color: Colors.grey.shade300),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
Container(
|
|
||||||
height: 14,
|
|
||||||
width: 50,
|
|
||||||
color: Colors.grey.shade300),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
)),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
@ -1,8 +1,9 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
class PillTabBar extends StatelessWidget {
|
class PillTabBar extends StatefulWidget {
|
||||||
final TabController controller;
|
final TabController controller;
|
||||||
final List<String> tabs;
|
final List<String> tabs;
|
||||||
|
final List<IconData> icons;
|
||||||
final Color selectedColor;
|
final Color selectedColor;
|
||||||
final Color unselectedColor;
|
final Color unselectedColor;
|
||||||
final Color indicatorColor;
|
final Color indicatorColor;
|
||||||
@ -13,6 +14,7 @@ class PillTabBar extends StatelessWidget {
|
|||||||
Key? key,
|
Key? key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.tabs,
|
required this.tabs,
|
||||||
|
required this.icons,
|
||||||
this.selectedColor = Colors.blue,
|
this.selectedColor = Colors.blue,
|
||||||
this.unselectedColor = Colors.grey,
|
this.unselectedColor = Colors.grey,
|
||||||
this.indicatorColor = Colors.blueAccent,
|
this.indicatorColor = Colors.blueAccent,
|
||||||
@ -21,64 +23,80 @@ class PillTabBar extends StatelessWidget {
|
|||||||
}) : super(key: key);
|
}) : super(key: key);
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
State<PillTabBar> createState() => _PillTabBarState();
|
||||||
// Dynamic horizontal padding between tabs
|
}
|
||||||
final screenWidth = MediaQuery.of(context).size.width;
|
|
||||||
final tabSpacing = (screenWidth / (tabs.length * 12)).clamp(8.0, 24.0);
|
|
||||||
|
|
||||||
|
class _PillTabBarState extends State<PillTabBar> {
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
widget.controller.addListener(_onTabChange);
|
||||||
|
}
|
||||||
|
|
||||||
|
void _onTabChange() {
|
||||||
|
if (mounted) setState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
widget.controller.removeListener(_onTabChange);
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||||
child: Container(
|
child: Container(
|
||||||
height: height,
|
height: widget.height,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(height / 2),
|
borderRadius: BorderRadius.circular(widget.height / 2),
|
||||||
boxShadow: [
|
),
|
||||||
BoxShadow(
|
child: TabBar(
|
||||||
color: Colors.grey.withOpacity(0.15),
|
controller: widget.controller,
|
||||||
blurRadius: 4,
|
isScrollable: true, // important for dynamic spacing
|
||||||
offset: const Offset(0, 2),
|
indicatorSize: TabBarIndicatorSize.tab,
|
||||||
|
indicator: BoxDecoration(
|
||||||
|
color: widget.indicatorColor.withOpacity(0.2),
|
||||||
|
borderRadius: BorderRadius.circular(widget.height / 2),
|
||||||
),
|
),
|
||||||
],
|
onTap: widget.onTap,
|
||||||
),
|
tabs: List.generate(widget.tabs.length, (index) {
|
||||||
child: TabBar(
|
final isSelected = widget.controller.index == index;
|
||||||
controller: controller,
|
|
||||||
indicator: BoxDecoration(
|
return AnimatedContainer(
|
||||||
color: indicatorColor.withOpacity(0.2),
|
duration: const Duration(milliseconds: 200),
|
||||||
borderRadius: BorderRadius.circular(height / 2),
|
padding: EdgeInsets.symmetric(
|
||||||
),
|
horizontal:
|
||||||
indicatorSize: TabBarIndicatorSize.tab,
|
isSelected ? 12 : 6, // reduce padding for unselected tabs
|
||||||
indicatorPadding: EdgeInsets.symmetric(
|
|
||||||
horizontal: tabSpacing / 2,
|
|
||||||
vertical: 4,
|
|
||||||
),
|
|
||||||
labelColor: selectedColor,
|
|
||||||
unselectedLabelColor: unselectedColor,
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
unselectedLabelStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 13,
|
|
||||||
),
|
|
||||||
tabs: tabs
|
|
||||||
.map(
|
|
||||||
(text) => Tab(
|
|
||||||
child: Padding(
|
|
||||||
padding: EdgeInsets.symmetric(horizontal: tabSpacing),
|
|
||||||
child: Text(
|
|
||||||
text,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
maxLines: 2,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
)
|
child: Row(
|
||||||
.toList(),
|
mainAxisSize: MainAxisSize.min,
|
||||||
onTap: onTap,
|
children: [
|
||||||
),
|
Icon(
|
||||||
),
|
widget.icons[index],
|
||||||
|
size: isSelected ? 18 : 16,
|
||||||
|
color: isSelected
|
||||||
|
? widget.selectedColor
|
||||||
|
: widget.unselectedColor,
|
||||||
|
),
|
||||||
|
if (isSelected) ...[
|
||||||
|
const SizedBox(width: 4),
|
||||||
|
Text(
|
||||||
|
widget.tabs[index],
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: widget.selectedColor,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
)),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -92,7 +92,7 @@ class _RegularizeActionButtonState extends State<RegularizeActionButton> {
|
|||||||
);
|
);
|
||||||
|
|
||||||
if (success) {
|
if (success) {
|
||||||
widget.attendanceController.fetchEmployeesByProject(selectedProjectId);
|
widget.attendanceController.fetchTodaysAttendance(selectedProjectId);
|
||||||
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
|
widget.attendanceController.fetchAttendanceLogs(selectedProjectId);
|
||||||
await widget.attendanceController
|
await widget.attendanceController
|
||||||
.fetchRegularizationLogs(selectedProjectId);
|
.fetchRegularizationLogs(selectedProjectId);
|
||||||
|
|||||||
@ -24,9 +24,11 @@ class AssignTaskBottomSheet extends StatefulWidget {
|
|||||||
final String buildingName;
|
final String buildingName;
|
||||||
final String floorName;
|
final String floorName;
|
||||||
final String workAreaName;
|
final String workAreaName;
|
||||||
|
final String buildingId;
|
||||||
|
|
||||||
const AssignTaskBottomSheet({
|
const AssignTaskBottomSheet({
|
||||||
super.key,
|
super.key,
|
||||||
|
required this.buildingId,
|
||||||
required this.buildingName,
|
required this.buildingName,
|
||||||
required this.workLocation,
|
required this.workLocation,
|
||||||
required this.floorName,
|
required this.floorName,
|
||||||
@ -82,10 +84,6 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|||||||
serviceId: selectedService?.id,
|
serviceId: selectedService?.id,
|
||||||
organizationId: selectedOrganization?.id,
|
organizationId: selectedOrganization?.id,
|
||||||
);
|
);
|
||||||
await controller.fetchTaskData(
|
|
||||||
selectedProjectId,
|
|
||||||
serviceId: selectedService?.id,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -376,7 +374,7 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _onAssignTaskPressed() {
|
Future<void> _onAssignTaskPressed() async {
|
||||||
final selectedTeam = controller.selectedEmployees;
|
final selectedTeam = controller.selectedEmployees;
|
||||||
|
|
||||||
if (selectedTeam.isEmpty) {
|
if (selectedTeam.isEmpty) {
|
||||||
@ -417,14 +415,20 @@ class _AssignTaskBottomSheetState extends State<AssignTaskBottomSheet> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
controller.assignDailyTask(
|
final success = await controller.assignDailyTask(
|
||||||
workItemId: widget.workItemId,
|
workItemId: widget.workItemId,
|
||||||
plannedTask: target.toInt(),
|
plannedTask: target.toInt(),
|
||||||
description: description,
|
description: description,
|
||||||
taskTeam: selectedTeam.map((e) => e.id).toList(), // pass IDs
|
taskTeam: selectedTeam.map((e) => e.id).toList(),
|
||||||
assignmentDate: widget.assignmentDate,
|
assignmentDate: widget.assignmentDate,
|
||||||
|
buildingId: widget.buildingId,
|
||||||
|
projectId: selectedProjectId!,
|
||||||
organizationId: selectedOrganization?.id,
|
organizationId: selectedOrganization?.id,
|
||||||
serviceId: selectedService?.id,
|
serviceId: selectedService?.id,
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (success) {
|
||||||
|
Navigator.pop(context);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,11 +35,13 @@ class _UserDocumentFilterBottomSheetState
|
|||||||
if (filterData == null) return const SizedBox.shrink();
|
if (filterData == null) return const SizedBox.shrink();
|
||||||
|
|
||||||
final hasFilters = [
|
final hasFilters = [
|
||||||
filterData.uploadedBy,
|
filterData.uploadedBy,
|
||||||
filterData.documentCategory,
|
filterData.documentCategory,
|
||||||
filterData.documentType,
|
filterData.documentType,
|
||||||
filterData.documentTag,
|
filterData.documentTag,
|
||||||
].any((list) => list.isNotEmpty);
|
].any((list) => list.isNotEmpty) ||
|
||||||
|
docController.startDate.value != null ||
|
||||||
|
docController.endDate.value != null;
|
||||||
|
|
||||||
return BaseBottomSheet(
|
return BaseBottomSheet(
|
||||||
title: 'Filter Documents',
|
title: 'Filter Documents',
|
||||||
@ -53,8 +55,8 @@ class _UserDocumentFilterBottomSheetState
|
|||||||
'documentTypeIds': docController.selectedType.toList(),
|
'documentTypeIds': docController.selectedType.toList(),
|
||||||
'documentTagIds': docController.selectedTag.toList(),
|
'documentTagIds': docController.selectedTag.toList(),
|
||||||
'isUploadedAt': docController.isUploadedAt.value,
|
'isUploadedAt': docController.isUploadedAt.value,
|
||||||
'startDate': docController.startDate.value,
|
'startDate': docController.startDate.value?.toIso8601String(),
|
||||||
'endDate': docController.endDate.value,
|
'endDate': docController.endDate.value?.toIso8601String(),
|
||||||
if (docController.isVerified.value != null)
|
if (docController.isVerified.value != null)
|
||||||
'isVerified': docController.isVerified.value,
|
'isVerified': docController.isVerified.value,
|
||||||
};
|
};
|
||||||
|
|||||||
@ -52,6 +52,14 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
|||||||
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
|
final GlobalKey _expenseTypeDropdownKey = GlobalKey();
|
||||||
final GlobalKey _paymentModeDropdownKey = GlobalKey();
|
final GlobalKey _paymentModeDropdownKey = GlobalKey();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
if (widget.isEdit && widget.existingExpense != null) {
|
||||||
|
controller.populateFieldsForEdit(widget.existingExpense!);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Future<void> _showEmployeeList() async {
|
Future<void> _showEmployeeList() async {
|
||||||
final result = await showModalBottomSheet<dynamic>(
|
final result = await showModalBottomSheet<dynamic>(
|
||||||
context: context,
|
context: context,
|
||||||
@ -217,13 +225,6 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
_gap(),
|
_gap(),
|
||||||
_buildTextFieldSection(
|
|
||||||
icon: Icons.confirmation_number_outlined,
|
|
||||||
title: "GST No.",
|
|
||||||
controller: controller.gstController,
|
|
||||||
hint: "Enter GST No.",
|
|
||||||
),
|
|
||||||
_gap(),
|
|
||||||
_buildDropdownField<PaymentModeModel>(
|
_buildDropdownField<PaymentModeModel>(
|
||||||
icon: Icons.payment,
|
icon: Icons.payment,
|
||||||
title: "Payment Mode",
|
title: "Payment Mode",
|
||||||
@ -239,6 +240,29 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
|||||||
dropdownKey: _paymentModeDropdownKey,
|
dropdownKey: _paymentModeDropdownKey,
|
||||||
),
|
),
|
||||||
_gap(),
|
_gap(),
|
||||||
|
Obx(() {
|
||||||
|
if (controller.isTransactionIdExempted.value) {
|
||||||
|
return const SizedBox.shrink(); // hide field
|
||||||
|
}
|
||||||
|
|
||||||
|
return Column(
|
||||||
|
children: [
|
||||||
|
_buildTextFieldSection(
|
||||||
|
icon: Icons.confirmation_number_outlined,
|
||||||
|
title: "Transaction ID",
|
||||||
|
hint: "Enter Transaction ID",
|
||||||
|
controller: controller.transactionIdController,
|
||||||
|
isRequiredOverride: true,
|
||||||
|
validator: (v) {
|
||||||
|
return (v != null && v.isNotEmpty)
|
||||||
|
? Validators.transactionIdValidator(v)
|
||||||
|
: Validators.requiredField(v);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
_gap(),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}),
|
||||||
_buildPaidBySection(),
|
_buildPaidBySection(),
|
||||||
_gap(),
|
_gap(),
|
||||||
_buildTextFieldSection(
|
_buildTextFieldSection(
|
||||||
@ -262,12 +286,9 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
|||||||
_gap(),
|
_gap(),
|
||||||
_buildTextFieldSection(
|
_buildTextFieldSection(
|
||||||
icon: Icons.confirmation_number_outlined,
|
icon: Icons.confirmation_number_outlined,
|
||||||
title: "Transaction ID",
|
title: "GST No.",
|
||||||
controller: controller.transactionIdController,
|
controller: controller.gstController,
|
||||||
hint: "Enter Transaction ID",
|
hint: "Enter GST No.",
|
||||||
validator: (v) => (v != null && v.isNotEmpty)
|
|
||||||
? Validators.transactionIdValidator(v)
|
|
||||||
: null,
|
|
||||||
),
|
),
|
||||||
_gap(),
|
_gap(),
|
||||||
_buildTransactionDateField(),
|
_buildTransactionDateField(),
|
||||||
@ -321,12 +342,18 @@ class _AddExpenseBottomSheetState extends State<_AddExpenseBottomSheet>
|
|||||||
TextInputType? keyboardType,
|
TextInputType? keyboardType,
|
||||||
FormFieldValidator<String>? validator,
|
FormFieldValidator<String>? validator,
|
||||||
int maxLines = 1,
|
int maxLines = 1,
|
||||||
|
bool? isRequiredOverride,
|
||||||
}) {
|
}) {
|
||||||
|
final bool isRequired = isRequiredOverride ?? (validator != null);
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
SectionTitle(
|
SectionTitle(
|
||||||
icon: icon, title: title, requiredField: validator != null),
|
icon: icon,
|
||||||
|
title: title,
|
||||||
|
requiredField: isRequired
|
||||||
|
),
|
||||||
MySpacing.height(6),
|
MySpacing.height(6),
|
||||||
CustomTextField(
|
CustomTextField(
|
||||||
controller: controller,
|
controller: controller,
|
||||||
|
|||||||
@ -41,7 +41,7 @@ class ReimbursementBottomSheet extends StatefulWidget {
|
|||||||
|
|
||||||
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
||||||
final ExpenseDetailController controller =
|
final ExpenseDetailController controller =
|
||||||
Get.find<ExpenseDetailController>();
|
Get.put(ExpenseDetailController());
|
||||||
|
|
||||||
final TextEditingController commentCtrl = TextEditingController();
|
final TextEditingController commentCtrl = TextEditingController();
|
||||||
final TextEditingController txnCtrl = TextEditingController();
|
final TextEditingController txnCtrl = TextEditingController();
|
||||||
@ -197,7 +197,7 @@ class _ReimbursementBottomSheetState extends State<ReimbursementBottomSheet> {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (expenseTransactionDate != null && selectedDate != null) {
|
if (expenseTransactionDate != null) {
|
||||||
final normalizedSelected = DateTime(
|
final normalizedSelected = DateTime(
|
||||||
selectedDate.year,
|
selectedDate.year,
|
||||||
selectedDate.month,
|
selectedDate.month,
|
||||||
|
|||||||
@ -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,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
119
lib/model/infra_project/infra_team_list_model.dart
Normal file
119
lib/model/infra_project/infra_team_list_model.dart
Normal 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
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -1,7 +1,6 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:get/get.dart';
|
import 'package:get/get.dart';
|
||||||
import 'package:on_field_work/helpers/services/auth_service.dart';
|
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
|
||||||
import 'package:on_field_work/view/auth/forgot_password_screen.dart';
|
import 'package:on_field_work/view/auth/forgot_password_screen.dart';
|
||||||
import 'package:on_field_work/view/auth/login_screen.dart';
|
import 'package:on_field_work/view/auth/login_screen.dart';
|
||||||
import 'package:on_field_work/view/auth/register_account_screen.dart';
|
import 'package:on_field_work/view/auth/register_account_screen.dart';
|
||||||
@ -32,7 +31,7 @@ class AuthMiddleware extends GetMiddleware {
|
|||||||
if (route != '/auth/login-option') {
|
if (route != '/auth/login-option') {
|
||||||
return const RouteSettings(name: '/auth/login-option');
|
return const RouteSettings(name: '/auth/login-option');
|
||||||
}
|
}
|
||||||
} else if (!TenantService.isTenantSelected) {
|
} else if (!AuthService.isTenantSelected) {
|
||||||
if (route != '/select-tenant') {
|
if (route != '/select-tenant') {
|
||||||
return const RouteSettings(name: '/select-tenant');
|
return const RouteSettings(name: '/select-tenant');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -31,7 +31,7 @@ class _AttendanceScreenState extends State<AttendanceScreen>
|
|||||||
final projectController = Get.put(ProjectController());
|
final projectController = Get.put(ProjectController());
|
||||||
|
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
late List<Map<String, String>> _tabs;
|
late List<Map<String, dynamic>> _tabs;
|
||||||
bool _tabsInitialized = false;
|
bool _tabsInitialized = false;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -62,9 +62,13 @@ class _AttendanceScreenState extends State<AttendanceScreen>
|
|||||||
|
|
||||||
void _initializeTabs() async {
|
void _initializeTabs() async {
|
||||||
final allTabs = [
|
final allTabs = [
|
||||||
{'label': "Today's", 'value': 'todaysAttendance'},
|
{'label': "Today's", 'value': 'todaysAttendance', 'icon': Icons.today},
|
||||||
{'label': "Logs", 'value': 'attendanceLogs'},
|
{'label': "Logs", 'value': 'attendanceLogs', 'icon': Icons.list_alt},
|
||||||
{'label': "Regularization", 'value': 'regularizationRequests'},
|
{
|
||||||
|
'label': "Regularization",
|
||||||
|
'value': 'regularizationRequests',
|
||||||
|
'icon': Icons.edit
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
final hasRegularizationPermission =
|
final hasRegularizationPermission =
|
||||||
@ -306,7 +310,11 @@ class _AttendanceScreenState extends State<AttendanceScreen>
|
|||||||
padding: const EdgeInsets.symmetric(horizontal: 8),
|
padding: const EdgeInsets.symmetric(horizontal: 8),
|
||||||
child: PillTabBar(
|
child: PillTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: _tabs.map((e) => e['label']!).toList(),
|
tabs:
|
||||||
|
_tabs.map((e) => e['label'] as String).toList(),
|
||||||
|
icons: _tabs
|
||||||
|
.map((e) => e['icon'] as IconData)
|
||||||
|
.toList(),
|
||||||
selectedColor: contentTheme.primary,
|
selectedColor: contentTheme.primary,
|
||||||
unselectedColor: Colors.grey.shade600,
|
unselectedColor: Colors.grey.shade600,
|
||||||
indicatorColor: contentTheme.primary,
|
indicatorColor: contentTheme.primary,
|
||||||
|
|||||||
@ -123,14 +123,33 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
|||||||
Widget _buildWelcomeText() {
|
Widget _buildWelcomeText() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
MyText(
|
RichText(
|
||||||
"Welcome to On Field Work",
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 600,
|
|
||||||
color: Colors.black87,
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
text: TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
children: const [
|
||||||
|
TextSpan(
|
||||||
|
text: "Welcome to ",
|
||||||
|
style: TextStyle(color: Colors.black87),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: "OnField",
|
||||||
|
style: TextStyle(color: Color(0xFF007BFF)), // Blue
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: "Work",
|
||||||
|
style: TextStyle(color: Color(0xFF71DD37)), // Green
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
MyText(
|
MyText(
|
||||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
@ -254,7 +273,11 @@ class _ForgotPasswordScreenState extends State<ForgotPasswordScreen>
|
|||||||
Widget _buildBackButton() {
|
Widget _buildBackButton() {
|
||||||
return TextButton.icon(
|
return TextButton.icon(
|
||||||
onPressed: () async => await LocalStorage.logout(),
|
onPressed: () async => await LocalStorage.logout(),
|
||||||
icon: Icon(Icons.arrow_back, size: 18, color: contentTheme.primary,),
|
icon: Icon(
|
||||||
|
Icons.arrow_back,
|
||||||
|
size: 18,
|
||||||
|
color: contentTheme.primary,
|
||||||
|
),
|
||||||
label: MyText.bodyMedium(
|
label: MyText.bodyMedium(
|
||||||
'Back to Login',
|
'Back to Login',
|
||||||
color: contentTheme.primary,
|
color: contentTheme.primary,
|
||||||
|
|||||||
@ -196,14 +196,33 @@ class _WelcomeScreenState extends State<WelcomeScreen>
|
|||||||
Widget _buildWelcomeText() {
|
Widget _buildWelcomeText() {
|
||||||
return Column(
|
return Column(
|
||||||
children: [
|
children: [
|
||||||
MyText(
|
RichText(
|
||||||
"Welcome to On Field Work",
|
|
||||||
fontSize: 26,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: Colors.black87,
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
text: TextSpan(
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 26,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
children: const [
|
||||||
|
TextSpan(
|
||||||
|
text: "Welcome to ",
|
||||||
|
style: TextStyle(color: Colors.black87),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: "OnField",
|
||||||
|
style: TextStyle(color: Color(0xFF007BFF)), // Blue
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: "Work",
|
||||||
|
style: TextStyle(color: Color(0xFF71DD37)), // Green
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
|
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
|
|
||||||
MyText(
|
MyText(
|
||||||
"Streamline Project Management\nBoost Productivity with Automation.",
|
"Streamline Project Management\nBoost Productivity with Automation.",
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
|||||||
@ -10,7 +10,6 @@ import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
|||||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/wave_background.dart';
|
import 'package:on_field_work/helpers/widgets/wave_background.dart';
|
||||||
|
|
||||||
|
|
||||||
class MPINAuthScreen extends StatefulWidget {
|
class MPINAuthScreen extends StatefulWidget {
|
||||||
const MPINAuthScreen({super.key});
|
const MPINAuthScreen({super.key});
|
||||||
|
|
||||||
@ -91,12 +90,31 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
const SizedBox(height: 12),
|
const SizedBox(height: 12),
|
||||||
MyText(
|
RichText(
|
||||||
"Welcome to On Field Work",
|
|
||||||
fontSize: 24,
|
|
||||||
fontWeight: 800,
|
|
||||||
color: Colors.black87,
|
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
|
text: const TextSpan(
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 24,
|
||||||
|
fontWeight: FontWeight.w800,
|
||||||
|
color: Colors.black87,
|
||||||
|
),
|
||||||
|
children: [
|
||||||
|
TextSpan(
|
||||||
|
text: "Welcome to ",
|
||||||
|
style: TextStyle(color: Colors.black87),
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: "OnField",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF007BFF)), // Blue
|
||||||
|
),
|
||||||
|
TextSpan(
|
||||||
|
text: "Work",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF71DD37)), // Green
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
const SizedBox(height: 10),
|
||||||
MyText(
|
MyText(
|
||||||
@ -317,8 +335,8 @@ class _MPINAuthScreenState extends State<MPINAuthScreen>
|
|||||||
if (isNewUser || isChangeMpin)
|
if (isNewUser || isChangeMpin)
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () => Get.toNamed('/dashboard'),
|
onPressed: () => Get.toNamed('/dashboard'),
|
||||||
icon: Icon(Icons.arrow_back,
|
icon:
|
||||||
size: 18, color: contentTheme.primary),
|
Icon(Icons.arrow_back, size: 18, color: contentTheme.primary),
|
||||||
label: MyText.bodyMedium(
|
label: MyText.bodyMedium(
|
||||||
'Back to Home Page',
|
'Back to Home Page',
|
||||||
color: contentTheme.primary,
|
color: contentTheme.primary,
|
||||||
|
|||||||
@ -11,8 +11,8 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
|
|||||||
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
import 'package:on_field_work/helpers/widgets/dashbaord/expense_breakdown_chart.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
import 'package:on_field_work/helpers/widgets/dashbaord/expense_by_status_widget.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart';
|
// import 'package:on_field_work/helpers/widgets/dashbaord/collection_dashboard_card.dart'; // Unused
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart';
|
// import 'package:on_field_work/helpers/widgets/dashbaord/purchase_invoice_dashboard.dart'; // Unused
|
||||||
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
|
import 'package:on_field_work/helpers/widgets/dashbaord/monthly_expense_dashboard_chart.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||||
@ -34,7 +34,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
final AttendanceController attendanceController =
|
final AttendanceController attendanceController =
|
||||||
Get.put(AttendanceController());
|
Get.put(AttendanceController());
|
||||||
final DynamicMenuController menuController = Get.put(DynamicMenuController());
|
final DynamicMenuController menuController = Get.put(DynamicMenuController());
|
||||||
final ProjectController projectController = Get.find<ProjectController>();
|
final ProjectController projectController = Get.put(ProjectController());
|
||||||
|
|
||||||
bool hasMpin = true;
|
bool hasMpin = true;
|
||||||
|
|
||||||
@ -56,34 +56,35 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
Widget _cardWrapper({required Widget child}) {
|
Widget _cardWrapper({required Widget child}) {
|
||||||
|
const BorderRadius cardRadius = BorderRadius.all(Radius.circular(5));
|
||||||
|
const List<BoxShadow> cardShadow = [
|
||||||
|
BoxShadow(
|
||||||
|
color: Color.fromRGBO(0, 0, 0, 0.05),
|
||||||
|
blurRadius: 12,
|
||||||
|
offset: Offset(0, 4),
|
||||||
|
),
|
||||||
|
];
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
margin: const EdgeInsets.only(bottom: 16),
|
margin: const EdgeInsets.only(bottom: 16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: cardRadius,
|
||||||
border: Border.all(color: Colors.black12.withOpacity(.04)),
|
border: Border.all(color: Colors.black12.withOpacity(.04)),
|
||||||
boxShadow: [
|
boxShadow: cardShadow,
|
||||||
BoxShadow(
|
|
||||||
color: Colors.black12.withOpacity(.05),
|
|
||||||
blurRadius: 12,
|
|
||||||
offset: const Offset(0, 4),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
child: child,
|
child: child,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
Widget _sectionTitle(String title) {
|
Widget _sectionTitle(String title) {
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
padding: const EdgeInsets.only(left: 4, bottom: 8),
|
||||||
child: Text(
|
child: MyText.titleMedium(
|
||||||
title,
|
title,
|
||||||
style: const TextStyle(
|
fontWeight: 700,
|
||||||
fontSize: 16,
|
color: Colors.black87,
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -120,9 +121,33 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
end: Alignment.bottomRight,
|
end: Alignment.bottomRight,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: const Text(
|
child: Column(
|
||||||
'No attendance data available',
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
style: TextStyle(color: Colors.white),
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Row(
|
||||||
|
children: [
|
||||||
|
const Icon(Icons.info_outline,
|
||||||
|
size: 30, color: Colors.white),
|
||||||
|
MySpacing.width(10),
|
||||||
|
const Expanded(
|
||||||
|
child: Text(
|
||||||
|
"No attendance data available yet.",
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 13,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
const Text(
|
||||||
|
"You are not added to this project or attendance data is not available.",
|
||||||
|
style: TextStyle(color: Colors.white70, fontSize: 13),
|
||||||
|
),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -137,6 +162,12 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
? 'Checked In'
|
? 'Checked In'
|
||||||
: 'Checked Out';
|
: 'Checked Out';
|
||||||
|
|
||||||
|
final String infoText = !isCheckedIn
|
||||||
|
? 'You are not checked-in yet. Please check-in to start your work.'
|
||||||
|
: !isCheckedOut
|
||||||
|
? 'You are currently checked-in. Don\'t forget to check-out after your work.'
|
||||||
|
: 'You have checked-out for today.';
|
||||||
|
|
||||||
return Container(
|
return Container(
|
||||||
padding: const EdgeInsets.all(16),
|
padding: const EdgeInsets.all(16),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -185,19 +216,15 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
MySpacing.height(12),
|
||||||
Text(
|
Text(
|
||||||
!isCheckedIn
|
infoText,
|
||||||
? 'You are not checked-in yet. Please check-in to start your work.'
|
|
||||||
: !isCheckedOut
|
|
||||||
? 'You are currently checked-in. Don\'t forget to check-out after your work.'
|
|
||||||
: 'You have checked-out for today.',
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
fontSize: 13,
|
fontSize: 13,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 12),
|
MySpacing.height(12),
|
||||||
Row(
|
Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.end,
|
mainAxisAlignment: MainAxisAlignment.end,
|
||||||
children: [
|
children: [
|
||||||
@ -236,8 +263,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
|
|
||||||
final bool projectSelected = projectController.selectedProject != null;
|
final bool projectSelected = projectController.selectedProject != null;
|
||||||
|
|
||||||
// these are String constants from permission_constants.dart
|
const List<String> cardOrder = [
|
||||||
final List<String> cardOrder = [
|
|
||||||
MenuItems.attendance,
|
MenuItems.attendance,
|
||||||
MenuItems.employees,
|
MenuItems.employees,
|
||||||
MenuItems.directory,
|
MenuItems.directory,
|
||||||
@ -280,14 +306,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
child: Row(
|
child: Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
_sectionTitle('Modules'),
|
||||||
'Modules',
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w700,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
if (!projectSelected)
|
if (!projectSelected)
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.symmetric(
|
padding: const EdgeInsets.symmetric(
|
||||||
@ -312,7 +331,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
),
|
),
|
||||||
GridView.builder(
|
GridView.builder(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(), // Important!
|
||||||
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
|
padding: const EdgeInsets.symmetric(vertical: 2, horizontal: 2),
|
||||||
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
gridDelegate: const SliverGridDelegateWithFixedCrossAxisCount(
|
||||||
crossAxisCount: 3,
|
crossAxisCount: 3,
|
||||||
@ -326,8 +345,9 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
final item = allowed[id]!;
|
final item = allowed[id]!;
|
||||||
final _DashboardCardMeta cardMeta = meta[id]!;
|
final _DashboardCardMeta cardMeta = meta[id]!;
|
||||||
|
|
||||||
|
// Attendance is the only module not requiring a project
|
||||||
final bool isEnabled =
|
final bool isEnabled =
|
||||||
item.name == 'Attendance' ? true : projectSelected;
|
item.id == MenuItems.attendance ? true : projectSelected;
|
||||||
|
|
||||||
return GestureDetector(
|
return GestureDetector(
|
||||||
onTap: () {
|
onTap: () {
|
||||||
@ -371,7 +391,7 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
color:
|
color:
|
||||||
isEnabled ? cardMeta.color : Colors.grey.shade300,
|
isEnabled ? cardMeta.color : Colors.grey.shade300,
|
||||||
),
|
),
|
||||||
const SizedBox(height: 6),
|
MySpacing.height(6),
|
||||||
Padding(
|
Padding(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 2),
|
padding: const EdgeInsets.symmetric(horizontal: 2),
|
||||||
child: Text(
|
child: Text(
|
||||||
@ -413,10 +433,14 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
final String? selectedId = projectController.selectedProjectId.value;
|
final String? selectedId = projectController.selectedProjectId.value;
|
||||||
|
|
||||||
if (isLoading) {
|
if (isLoading) {
|
||||||
return SkeletonLoaders.dashboardCardsSkeleton(
|
return SkeletonLoaders.projectSelectorSkeleton();
|
||||||
maxWidth: MediaQuery.of(context).size.width,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
final String selectedProjectName = projects
|
||||||
|
.firstWhereOrNull(
|
||||||
|
(p) => p.id == selectedId,
|
||||||
|
)
|
||||||
|
?.name ??
|
||||||
|
'Select Project';
|
||||||
|
|
||||||
return Column(
|
return Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
@ -445,15 +469,10 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
color: Colors.blue,
|
color: Colors.blue,
|
||||||
size: 20,
|
size: 20,
|
||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
MySpacing.width(12),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Text(
|
child: Text(
|
||||||
projects
|
selectedProjectName,
|
||||||
.firstWhereOrNull(
|
|
||||||
(p) => p.id == selectedId,
|
|
||||||
)
|
|
||||||
?.name ??
|
|
||||||
'Select Project',
|
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15,
|
fontSize: 15,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
@ -498,17 +517,17 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
),
|
),
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
TextField(
|
const TextField(
|
||||||
decoration: InputDecoration(
|
decoration: InputDecoration(
|
||||||
hintText: 'Search project...',
|
hintText: 'Search project...',
|
||||||
isDense: true,
|
isDense: true,
|
||||||
prefixIcon: const Icon(Icons.search),
|
prefixIcon: Icon(Icons.search),
|
||||||
border: OutlineInputBorder(
|
border: OutlineInputBorder(
|
||||||
borderRadius: BorderRadius.circular(5),
|
borderRadius: BorderRadius.all(Radius.circular(5)),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 10),
|
MySpacing.height(10),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: ListView.builder(
|
child: ListView.builder(
|
||||||
itemCount: projects.length,
|
itemCount: projects.length,
|
||||||
@ -534,51 +553,62 @@ class _DashboardScreenState extends State<DashboardScreen> with UIMixin {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Build
|
// Build (MODIFIED FOR FIXED HEADER)
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: const Color(0xfff5f6fa),
|
backgroundColor: const Color(0xfff5f6fa),
|
||||||
body: Layout(
|
body: Layout(
|
||||||
child: SingleChildScrollView(
|
child: Stack(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
|
children: [
|
||||||
child: Column(
|
// Main content
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
Padding(
|
||||||
children: [
|
padding:
|
||||||
_projectSelector(),
|
const EdgeInsets.symmetric(horizontal: 10, vertical: 0),
|
||||||
MySpacing.height(20),
|
child: Column(
|
||||||
_quickActions(),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
MySpacing.height(20),
|
children: [
|
||||||
_dashboardModules(),
|
_projectSelector(),
|
||||||
MySpacing.height(20),
|
MySpacing.height(20),
|
||||||
_sectionTitle('Reports & Analytics'),
|
Expanded(
|
||||||
CompactPurchaseInvoiceDashboard(),
|
child: SingleChildScrollView(
|
||||||
MySpacing.height(20),
|
child: Column(
|
||||||
CollectionsHealthWidget(),
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
MySpacing.height(20),
|
children: [
|
||||||
_cardWrapper(
|
_quickActions(),
|
||||||
child: ExpenseTypeReportChart(),
|
MySpacing.height(20),
|
||||||
),
|
_dashboardModules(),
|
||||||
_cardWrapper(
|
MySpacing.height(20),
|
||||||
child: ExpenseByStatusWidget(
|
_sectionTitle('Reports & Analytics'),
|
||||||
controller: dashboardController,
|
_cardWrapper(
|
||||||
|
child: ExpenseTypeReportChart(),
|
||||||
|
),
|
||||||
|
_cardWrapper(
|
||||||
|
child: ExpenseByStatusWidget(
|
||||||
|
controller: dashboardController,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
_cardWrapper(
|
||||||
|
child: MonthlyExpenseDashboardChart(),
|
||||||
|
),
|
||||||
|
MySpacing.height(80), // give space under content
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
_cardWrapper(
|
),
|
||||||
child: MonthlyExpenseDashboardChart(),
|
|
||||||
),
|
|
||||||
MySpacing.height(20),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
],
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
}
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
class _DashboardCardMeta {
|
class _DashboardCardMeta {
|
||||||
final IconData icon;
|
final IconData icon;
|
||||||
final Color color;
|
final Color color;
|
||||||
|
|||||||
@ -70,6 +70,7 @@ class _ContactDetailScreenState extends State<ContactDetailScreen>
|
|||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: 'Contact Profile',
|
title: 'Contact Profile',
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
|
projectName: " All Projects",
|
||||||
onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
|
onBackPressed: () => Get.offAllNamed('/dashboard/directory-main-page'),
|
||||||
),
|
),
|
||||||
|
|
||||||
|
|||||||
@ -46,6 +46,7 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
|
|||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Directory",
|
title: "Directory",
|
||||||
onBackPressed: () => Get.offNamed('/dashboard'),
|
onBackPressed: () => Get.offNamed('/dashboard'),
|
||||||
|
projectName: " All Projects",
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@ -73,6 +74,10 @@ class _DirectoryMainScreenState extends State<DirectoryMainScreen>
|
|||||||
PillTabBar(
|
PillTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const ["Directory", "Notes"],
|
tabs: const ["Directory", "Notes"],
|
||||||
|
icons: const [
|
||||||
|
Icons.people,
|
||||||
|
Icons.notes_outlined,
|
||||||
|
],
|
||||||
selectedColor: contentTheme.primary,
|
selectedColor: contentTheme.primary,
|
||||||
unselectedColor: Colors.grey.shade600,
|
unselectedColor: Colors.grey.shade600,
|
||||||
indicatorColor: contentTheme.primary,
|
indicatorColor: contentTheme.primary,
|
||||||
|
|||||||
@ -424,6 +424,8 @@ class _DirectoryViewState extends State<DirectoryView> with UIMixin {
|
|||||||
child: controller.isLoading.value
|
child: controller.isLoading.value
|
||||||
? ListView.separated(
|
? ListView.separated(
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
|
padding: MySpacing.only(
|
||||||
|
left: 10, right: 10, top: 4, bottom: 80),
|
||||||
itemCount: 10,
|
itemCount: 10,
|
||||||
separatorBuilder: (_, __) => MySpacing.height(12),
|
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||||
itemBuilder: (_, __) =>
|
itemBuilder: (_, __) =>
|
||||||
|
|||||||
@ -526,6 +526,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
|
|||||||
label: MyText(
|
label: MyText(
|
||||||
'Assign to Project',
|
'Assign to Project',
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
|
color: Colors.white,
|
||||||
fontWeight: 500,
|
fontWeight: 500,
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
@ -538,7 +539,7 @@ class _EmployeeDetailPageState extends State<EmployeeDetailPage> with UIMixin {
|
|||||||
if (managers.isEmpty) return '—';
|
if (managers.isEmpty) return '—';
|
||||||
return managers
|
return managers
|
||||||
.map((m) =>
|
.map((m) =>
|
||||||
'${(m.firstName ?? '').trim()} ${(m.lastName ?? '').trim()}'.trim())
|
'${(m.firstName ).trim()} ${(m.lastName ).trim()}'.trim())
|
||||||
.where((name) => name.isNotEmpty)
|
.where((name) => name.isNotEmpty)
|
||||||
.join(', ');
|
.join(', ');
|
||||||
}
|
}
|
||||||
|
|||||||
@ -4,6 +4,7 @@ import 'package:on_field_work/view/employees/employee_detail_screen.dart';
|
|||||||
import 'package:on_field_work/view/document/user_document_screen.dart';
|
import 'package:on_field_work/view/document/user_document_screen.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
||||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart'; // <-- import PillTabBar
|
||||||
|
|
||||||
class EmployeeProfilePage extends StatefulWidget {
|
class EmployeeProfilePage extends StatefulWidget {
|
||||||
final String employeeId;
|
final String employeeId;
|
||||||
@ -16,14 +17,11 @@ class EmployeeProfilePage extends StatefulWidget {
|
|||||||
|
|
||||||
class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
||||||
with SingleTickerProviderStateMixin, UIMixin {
|
with SingleTickerProviderStateMixin, UIMixin {
|
||||||
// We no longer need to listen to the TabController for setState,
|
|
||||||
// as the TabBar handles its own state updates via the controller.
|
|
||||||
late TabController _tabController;
|
late TabController _tabController;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// Initialize TabController with 2 tabs
|
|
||||||
_tabController = TabController(length: 2, vsync: this);
|
_tabController = TabController(length: 2, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -33,11 +31,8 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
// --- No need for _buildSegmentedButton function anymore ---
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
// Accessing theme colors for consistency
|
|
||||||
final Color appBarColor = contentTheme.primary;
|
final Color appBarColor = contentTheme.primary;
|
||||||
final Color primaryColor = contentTheme.primary;
|
final Color primaryColor = contentTheme.primary;
|
||||||
|
|
||||||
@ -45,13 +40,13 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
|||||||
backgroundColor: const Color(0xFFF1F1F1),
|
backgroundColor: const Color(0xFFF1F1F1),
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Employee Profile",
|
title: "Employee Profile",
|
||||||
|
projectName: " All Projects",
|
||||||
onBackPressed: () => Get.back(),
|
onBackPressed: () => Get.back(),
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
// === Gradient at the top behind AppBar + Toggle ===
|
// Gradient at the top behind AppBar + Toggle
|
||||||
// This container ensures the background color transitions nicely
|
|
||||||
Container(
|
Container(
|
||||||
height: 50,
|
height: 50,
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -65,63 +60,20 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
|||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
// === Main Content Area ===
|
|
||||||
SafeArea(
|
SafeArea(
|
||||||
top: false,
|
top: false,
|
||||||
bottom: true,
|
bottom: true,
|
||||||
child: Column(
|
child: Column(
|
||||||
children: [
|
children: [
|
||||||
// 🛑 NEW: The Modern TabBar Implementation 🛑
|
PillTabBar(
|
||||||
Padding(
|
controller: _tabController,
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 8.0),
|
tabs: const ["Details", "Documents"],
|
||||||
child: Container(
|
icons: const [Icons.person, Icons.folder],
|
||||||
height: 48, // Define a specific height for the TabBar container
|
selectedColor: primaryColor,
|
||||||
decoration: BoxDecoration(
|
unselectedColor: Colors.grey.shade600,
|
||||||
color: Colors.white,
|
indicatorColor: primaryColor,
|
||||||
borderRadius: BorderRadius.circular(24.0), // Rounded corners for a chip-like look
|
height: 48,
|
||||||
boxShadow: [
|
|
||||||
BoxShadow(
|
|
||||||
color: Colors.grey.withOpacity(0.15),
|
|
||||||
blurRadius: 4,
|
|
||||||
offset: const Offset(0, 2),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
child: TabBar(
|
|
||||||
controller: _tabController,
|
|
||||||
// Style the indicator as a subtle pill/chip
|
|
||||||
indicator: BoxDecoration(
|
|
||||||
color: primaryColor.withOpacity(0.1), // Light background color for the selection
|
|
||||||
borderRadius: BorderRadius.circular(24.0),
|
|
||||||
),
|
|
||||||
indicatorSize: TabBarIndicatorSize.tab,
|
|
||||||
// The padding is used to slightly shrink the indicator area
|
|
||||||
indicatorPadding: const EdgeInsets.symmetric(horizontal: 4.0, vertical: 4.0),
|
|
||||||
|
|
||||||
// Text styling
|
|
||||||
labelColor: primaryColor, // Selected text color is primary
|
|
||||||
unselectedLabelColor: Colors.grey.shade600, // Unselected text color is darker grey
|
|
||||||
labelStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
unselectedLabelStyle: const TextStyle(
|
|
||||||
fontWeight: FontWeight.w500,
|
|
||||||
fontSize: 15,
|
|
||||||
),
|
|
||||||
|
|
||||||
// Tabs (No custom widget needed, just use the built-in Tab)
|
|
||||||
tabs: const [
|
|
||||||
Tab(text: "Details"),
|
|
||||||
Tab(text: "Documents"),
|
|
||||||
],
|
|
||||||
// Setting this to zero removes the default underline
|
|
||||||
dividerColor: Colors.transparent,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// 🛑 TabBarView (The Content) 🛑
|
|
||||||
Expanded(
|
Expanded(
|
||||||
child: TabBarView(
|
child: TabBarView(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
@ -144,4 +96,4 @@ class _EmployeeProfilePageState extends State<EmployeeProfilePage>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -7,9 +7,7 @@ import 'package:on_field_work/helpers/widgets/my_text.dart';
|
|||||||
import 'package:on_field_work/model/employees/add_employee_bottom_sheet.dart';
|
import 'package:on_field_work/model/employees/add_employee_bottom_sheet.dart';
|
||||||
import 'package:on_field_work/controller/employee/employees_screen_controller.dart';
|
import 'package:on_field_work/controller/employee/employees_screen_controller.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||||
import 'package:on_field_work/controller/project_controller.dart';
|
|
||||||
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||||
import 'package:on_field_work/model/employees/employee_model.dart';
|
|
||||||
import 'package:on_field_work/helpers/utils/launcher_utils.dart';
|
import 'package:on_field_work/helpers/utils/launcher_utils.dart';
|
||||||
import 'package:on_field_work/view/employees/assign_employee_bottom_sheet.dart';
|
import 'package:on_field_work/view/employees/assign_employee_bottom_sheet.dart';
|
||||||
import 'package:on_field_work/controller/permission_controller.dart';
|
import 'package:on_field_work/controller/permission_controller.dart';
|
||||||
@ -30,56 +28,27 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
late final EmployeesScreenController _employeeController;
|
late final EmployeesScreenController _employeeController;
|
||||||
late final PermissionController _permissionController;
|
late final PermissionController _permissionController;
|
||||||
final TextEditingController _searchController = TextEditingController();
|
final TextEditingController _searchController = TextEditingController();
|
||||||
final RxList<EmployeeModel> _filteredEmployees = <EmployeeModel>[].obs;
|
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_employeeController = Get.put(EmployeesScreenController());
|
_employeeController = Get.put(EmployeesScreenController());
|
||||||
_permissionController = Get.put(PermissionController());
|
_permissionController = Get.put(PermissionController());
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) async {
|
_searchController.addListener(() {
|
||||||
await _initEmployees();
|
_employeeController.searchEmployees(_searchController.text);
|
||||||
_searchController.addListener(() {
|
|
||||||
_filterEmployees(_searchController.text);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _initEmployees() async {
|
|
||||||
await _employeeController.fetchAllEmployees();
|
|
||||||
_filterEmployees(_searchController.text);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _refreshEmployees() async {
|
Future<void> _refreshEmployees() async {
|
||||||
try {
|
try {
|
||||||
await _employeeController.fetchAllEmployees();
|
await _employeeController.fetchAllEmployees();
|
||||||
_filterEmployees(_searchController.text);
|
_employeeController.searchEmployees(_searchController.text);
|
||||||
_employeeController.update(['employee_screen_controller']);
|
|
||||||
} catch (e, stackTrace) {
|
} catch (e, stackTrace) {
|
||||||
debugPrint('Error refreshing employee data: $e');
|
debugPrint('Error refreshing employee data: $e');
|
||||||
debugPrintStack(stackTrace: stackTrace);
|
debugPrintStack(stackTrace: stackTrace);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _filterEmployees(String query) {
|
|
||||||
final employees = _employeeController.employees;
|
|
||||||
final searchQuery = query.toLowerCase();
|
|
||||||
final filtered = query.isEmpty
|
|
||||||
? List<EmployeeModel>.from(employees)
|
|
||||||
: employees
|
|
||||||
.where(
|
|
||||||
(e) =>
|
|
||||||
e.name.toLowerCase().contains(searchQuery) ||
|
|
||||||
e.email.toLowerCase().contains(searchQuery) ||
|
|
||||||
e.phoneNumber.toLowerCase().contains(searchQuery) ||
|
|
||||||
e.jobRole.toLowerCase().contains(searchQuery),
|
|
||||||
)
|
|
||||||
.toList();
|
|
||||||
filtered
|
|
||||||
.sort((a, b) => a.name.toLowerCase().compareTo(b.name.toLowerCase()));
|
|
||||||
_filteredEmployees.assignAll(filtered);
|
|
||||||
}
|
|
||||||
|
|
||||||
Future<void> _onAddNewEmployee() async {
|
Future<void> _onAddNewEmployee() async {
|
||||||
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
final result = await showModalBottomSheet<Map<String, dynamic>>(
|
||||||
context: context,
|
context: context,
|
||||||
@ -121,8 +90,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Employees",
|
title: "Employees",
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
projectName: Get.find<ProjectController>().selectedProject?.name ??
|
projectName: " All Projects",
|
||||||
'Select Project',
|
|
||||||
onBackPressed: () => Get.offNamed('/dashboard'),
|
onBackPressed: () => Get.offNamed('/dashboard'),
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@ -144,35 +112,27 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
|
|
||||||
// Main content
|
// Main content
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: GetBuilder<EmployeesScreenController>(
|
child: Obx(() {
|
||||||
init: _employeeController,
|
return MyRefreshIndicator(
|
||||||
tag: 'employee_screen_controller',
|
onRefresh: _refreshEmployees,
|
||||||
builder: (_) {
|
child: SingleChildScrollView(
|
||||||
_filterEmployees(_searchController.text);
|
physics: const AlwaysScrollableScrollPhysics(),
|
||||||
return MyRefreshIndicator(
|
padding: const EdgeInsets.only(bottom: 40),
|
||||||
onRefresh: _refreshEmployees,
|
child: Column(
|
||||||
child: SingleChildScrollView(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
physics: const AlwaysScrollableScrollPhysics(),
|
children: [
|
||||||
padding: const EdgeInsets.only(bottom: 40),
|
MySpacing.height(flexSpacing),
|
||||||
child: Column(
|
_buildSearchField(),
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
MySpacing.height(flexSpacing),
|
||||||
children: [
|
Padding(
|
||||||
MySpacing.height(flexSpacing),
|
padding: MySpacing.x(flexSpacing),
|
||||||
Padding(
|
child: _buildEmployeeList(),
|
||||||
padding: MySpacing.x(15),
|
),
|
||||||
child: _buildSearchField(),
|
],
|
||||||
),
|
|
||||||
MySpacing.height(flexSpacing),
|
|
||||||
Padding(
|
|
||||||
padding: MySpacing.x(flexSpacing),
|
|
||||||
child: _buildEmployeeList(),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
);
|
),
|
||||||
},
|
);
|
||||||
),
|
}),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -238,7 +198,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
size: 20, color: Colors.grey),
|
size: 20, color: Colors.grey),
|
||||||
onPressed: () {
|
onPressed: () {
|
||||||
_searchController.clear();
|
_searchController.clear();
|
||||||
_filterEmployees('');
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
@ -255,13 +214,11 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
onChanged: (_) => _filterEmployees(_searchController.text),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
MySpacing.width(10),
|
MySpacing.width(10),
|
||||||
|
|
||||||
// Three dots menu (Manage Reporting)
|
|
||||||
Container(
|
Container(
|
||||||
height: 35,
|
height: 35,
|
||||||
width: 35,
|
width: 35,
|
||||||
@ -277,10 +234,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
shape: RoundedRectangleBorder(
|
shape: RoundedRectangleBorder(
|
||||||
borderRadius: BorderRadius.circular(5)),
|
borderRadius: BorderRadius.circular(5)),
|
||||||
itemBuilder: (context) {
|
itemBuilder: (context) {
|
||||||
List<PopupMenuEntry<int>> menuItems = [];
|
return [
|
||||||
|
|
||||||
// Section: Actions
|
|
||||||
menuItems.add(
|
|
||||||
const PopupMenuItem<int>(
|
const PopupMenuItem<int>(
|
||||||
enabled: false,
|
enabled: false,
|
||||||
height: 30,
|
height: 30,
|
||||||
@ -290,10 +244,6 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
fontWeight: FontWeight.bold, color: Colors.grey),
|
fontWeight: FontWeight.bold, color: Colors.grey),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
|
||||||
|
|
||||||
// Manage Reporting option
|
|
||||||
menuItems.add(
|
|
||||||
PopupMenuItem<int>(
|
PopupMenuItem<int>(
|
||||||
value: 1,
|
value: 1,
|
||||||
child: Row(
|
child: Row(
|
||||||
@ -317,9 +267,7 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
});
|
});
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
);
|
];
|
||||||
|
|
||||||
return menuItems;
|
|
||||||
},
|
},
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -329,88 +277,85 @@ class _EmployeesScreenState extends State<EmployeesScreen> with UIMixin {
|
|||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildEmployeeList() {
|
Widget _buildEmployeeList() {
|
||||||
return Obx(() {
|
if (_employeeController.isLoading.value) {
|
||||||
if (_employeeController.isLoading.value) {
|
|
||||||
return ListView.separated(
|
|
||||||
shrinkWrap: true,
|
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
|
||||||
itemCount: 8,
|
|
||||||
separatorBuilder: (_, __) => MySpacing.height(12),
|
|
||||||
itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
final employees = _filteredEmployees;
|
|
||||||
if (employees.isEmpty) {
|
|
||||||
return Padding(
|
|
||||||
padding: const EdgeInsets.only(top: 60),
|
|
||||||
child: Center(
|
|
||||||
child: MyText.bodySmall("No Employees Found",
|
|
||||||
fontWeight: 600, color: Colors.grey[700]),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return ListView.separated(
|
return ListView.separated(
|
||||||
shrinkWrap: true,
|
shrinkWrap: true,
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
padding: MySpacing.only(bottom: 80),
|
itemCount: 8,
|
||||||
itemCount: employees.length,
|
|
||||||
separatorBuilder: (_, __) => MySpacing.height(12),
|
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||||
itemBuilder: (_, index) {
|
itemBuilder: (_, __) => SkeletonLoaders.employeeSkeletonCard(),
|
||||||
final e = employees[index];
|
|
||||||
final names = e.name.trim().split(' ');
|
|
||||||
final firstName = names.first;
|
|
||||||
final lastName = names.length > 1 ? names.last : '';
|
|
||||||
|
|
||||||
return InkWell(
|
|
||||||
onTap: () => Get.to(() => EmployeeProfilePage(employeeId: e.id)),
|
|
||||||
child: Row(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
Avatar(firstName: firstName, lastName: lastName, size: 35),
|
|
||||||
MySpacing.width(12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
|
||||||
children: [
|
|
||||||
MyText.titleSmall(e.name,
|
|
||||||
fontWeight: 600, overflow: TextOverflow.ellipsis),
|
|
||||||
if (e.jobRole.isNotEmpty)
|
|
||||||
MyText.bodySmall(e.jobRole,
|
|
||||||
color: Colors.grey[700],
|
|
||||||
overflow: TextOverflow.ellipsis),
|
|
||||||
MySpacing.height(8),
|
|
||||||
if (e.email.isNotEmpty && e.email != '-')
|
|
||||||
_buildLinkRow(
|
|
||||||
icon: Icons.email_outlined,
|
|
||||||
text: e.email,
|
|
||||||
onTap: () => LauncherUtils.launchEmail(e.email),
|
|
||||||
onLongPress: () => LauncherUtils.copyToClipboard(
|
|
||||||
e.email,
|
|
||||||
typeLabel: 'Email')),
|
|
||||||
if (e.email.isNotEmpty && e.email != '-')
|
|
||||||
MySpacing.height(6),
|
|
||||||
if (e.phoneNumber.isNotEmpty)
|
|
||||||
_buildLinkRow(
|
|
||||||
icon: Icons.phone_outlined,
|
|
||||||
text: e.phoneNumber,
|
|
||||||
onTap: () =>
|
|
||||||
LauncherUtils.launchPhone(e.phoneNumber),
|
|
||||||
onLongPress: () => LauncherUtils.copyToClipboard(
|
|
||||||
e.phoneNumber,
|
|
||||||
typeLabel: 'Phone')),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
const Icon(Icons.arrow_forward_ios,
|
|
||||||
color: Colors.grey, size: 16),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
});
|
}
|
||||||
|
|
||||||
|
final employees = _employeeController.filteredEmployees;
|
||||||
|
|
||||||
|
if (employees.isEmpty) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.only(top: 60),
|
||||||
|
child: Center(
|
||||||
|
child: MyText.bodySmall("No Employees Found",
|
||||||
|
fontWeight: 600, color: Colors.grey[700]),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return ListView.separated(
|
||||||
|
shrinkWrap: true,
|
||||||
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
|
padding: MySpacing.only(bottom: 80),
|
||||||
|
itemCount: employees.length,
|
||||||
|
separatorBuilder: (_, __) => MySpacing.height(12),
|
||||||
|
itemBuilder: (_, index) {
|
||||||
|
final e = employees[index];
|
||||||
|
final names = e.name.trim().split(' ');
|
||||||
|
final firstName = names.first;
|
||||||
|
final lastName = names.length > 1 ? names.last : '';
|
||||||
|
|
||||||
|
return InkWell(
|
||||||
|
onTap: () => Get.to(() => EmployeeProfilePage(employeeId: e.id)),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Avatar(firstName: firstName, lastName: lastName, size: 35),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleSmall(e.name,
|
||||||
|
fontWeight: 600, overflow: TextOverflow.ellipsis),
|
||||||
|
if (e.jobRole.isNotEmpty)
|
||||||
|
MyText.bodySmall(e.jobRole,
|
||||||
|
color: Colors.grey[700],
|
||||||
|
overflow: TextOverflow.ellipsis),
|
||||||
|
MySpacing.height(8),
|
||||||
|
if (e.email.isNotEmpty && e.email != '-')
|
||||||
|
_buildLinkRow(
|
||||||
|
icon: Icons.email_outlined,
|
||||||
|
text: e.email,
|
||||||
|
onTap: () => LauncherUtils.launchEmail(e.email),
|
||||||
|
onLongPress: () => LauncherUtils.copyToClipboard(
|
||||||
|
e.email,
|
||||||
|
typeLabel: 'Email')),
|
||||||
|
if (e.email.isNotEmpty && e.email != '-')
|
||||||
|
MySpacing.height(6),
|
||||||
|
if (e.phoneNumber.isNotEmpty)
|
||||||
|
_buildLinkRow(
|
||||||
|
icon: Icons.phone_outlined,
|
||||||
|
text: e.phoneNumber,
|
||||||
|
onTap: () => LauncherUtils.launchPhone(e.phoneNumber),
|
||||||
|
onLongPress: () => LauncherUtils.copyToClipboard(
|
||||||
|
e.phoneNumber,
|
||||||
|
typeLabel: 'Phone')),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const Icon(Icons.arrow_forward_ios, color: Colors.grey, size: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildLinkRow({
|
Widget _buildLinkRow({
|
||||||
|
|||||||
@ -36,6 +36,10 @@ class _ManageReportingBottomSheetState
|
|||||||
final TextEditingController _primaryController = TextEditingController();
|
final TextEditingController _primaryController = TextEditingController();
|
||||||
final TextEditingController _secondaryController = TextEditingController();
|
final TextEditingController _secondaryController = TextEditingController();
|
||||||
|
|
||||||
|
final FocusNode _mainEmployeeFocus = FocusNode();
|
||||||
|
final FocusNode _primaryFocus = FocusNode();
|
||||||
|
final FocusNode _secondaryFocus = FocusNode();
|
||||||
|
|
||||||
final RxList<EmployeeModel> _filteredPrimary = <EmployeeModel>[].obs;
|
final RxList<EmployeeModel> _filteredPrimary = <EmployeeModel>[].obs;
|
||||||
final RxList<EmployeeModel> _filteredSecondary = <EmployeeModel>[].obs;
|
final RxList<EmployeeModel> _filteredSecondary = <EmployeeModel>[].obs;
|
||||||
final RxList<EmployeeModel> _selectedPrimary = <EmployeeModel>[].obs;
|
final RxList<EmployeeModel> _selectedPrimary = <EmployeeModel>[].obs;
|
||||||
@ -69,6 +73,10 @@ class _ManageReportingBottomSheetState
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_mainEmployeeFocus.dispose();
|
||||||
|
_primaryFocus.dispose();
|
||||||
|
_secondaryFocus.dispose();
|
||||||
|
|
||||||
_primaryController.dispose();
|
_primaryController.dispose();
|
||||||
_secondaryController.dispose();
|
_secondaryController.dispose();
|
||||||
_selectEmployeeController.dispose();
|
_selectEmployeeController.dispose();
|
||||||
@ -368,6 +376,7 @@ class _ManageReportingBottomSheetState
|
|||||||
_buildSearchSection(
|
_buildSearchSection(
|
||||||
label: "Primary Reporting Manager*",
|
label: "Primary Reporting Manager*",
|
||||||
controller: _primaryController,
|
controller: _primaryController,
|
||||||
|
focusNode: _primaryFocus,
|
||||||
filteredList: _filteredPrimary,
|
filteredList: _filteredPrimary,
|
||||||
selectedList: _selectedPrimary,
|
selectedList: _selectedPrimary,
|
||||||
isPrimary: true,
|
isPrimary: true,
|
||||||
@ -379,6 +388,7 @@ class _ManageReportingBottomSheetState
|
|||||||
_buildSearchSection(
|
_buildSearchSection(
|
||||||
label: "Secondary Reporting Manager",
|
label: "Secondary Reporting Manager",
|
||||||
controller: _secondaryController,
|
controller: _secondaryController,
|
||||||
|
focusNode: _secondaryFocus,
|
||||||
filteredList: _filteredSecondary,
|
filteredList: _filteredSecondary,
|
||||||
selectedList: _selectedSecondary,
|
selectedList: _selectedSecondary,
|
||||||
isPrimary: false,
|
isPrimary: false,
|
||||||
@ -386,12 +396,13 @@ class _ManageReportingBottomSheetState
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
|
|
||||||
// 🔥 WRAP EVERYTHING IN SAFEAREA + SCROLL + BOTTOM PADDING
|
|
||||||
final safeWrappedContent = SafeArea(
|
final safeWrappedContent = SafeArea(
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: EdgeInsets.only(
|
padding: EdgeInsets.only(
|
||||||
bottom: MediaQuery.of(context).viewPadding.bottom + 20,
|
bottom: 8,
|
||||||
left: 16, right: 16, top: 8,
|
left: 16,
|
||||||
|
right: 16,
|
||||||
|
top: 8,
|
||||||
),
|
),
|
||||||
child: content,
|
child: content,
|
||||||
),
|
),
|
||||||
@ -417,7 +428,7 @@ class _ManageReportingBottomSheetState
|
|||||||
isSubmitting: _isSubmitting,
|
isSubmitting: _isSubmitting,
|
||||||
onCancel: _handleCancel,
|
onCancel: _handleCancel,
|
||||||
onSubmit: _handleSubmit,
|
onSubmit: _handleSubmit,
|
||||||
child: safeWrappedContent,
|
child: safeWrappedContent,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -449,6 +460,7 @@ class _ManageReportingBottomSheetState
|
|||||||
Widget _buildSearchSection({
|
Widget _buildSearchSection({
|
||||||
required String label,
|
required String label,
|
||||||
required TextEditingController controller,
|
required TextEditingController controller,
|
||||||
|
required FocusNode focusNode,
|
||||||
required RxList<EmployeeModel> filteredList,
|
required RxList<EmployeeModel> filteredList,
|
||||||
required RxList<EmployeeModel> selectedList,
|
required RxList<EmployeeModel> selectedList,
|
||||||
required bool isPrimary,
|
required bool isPrimary,
|
||||||
@ -459,20 +471,10 @@ class _ManageReportingBottomSheetState
|
|||||||
MyText.bodyMedium(label, fontWeight: 600),
|
MyText.bodyMedium(label, fontWeight: 600),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
|
|
||||||
// Search field
|
_searchBar(
|
||||||
TextField(
|
|
||||||
controller: controller,
|
controller: controller,
|
||||||
decoration: InputDecoration(
|
focusNode: focusNode,
|
||||||
hintText: "Type to search employees...",
|
hint: "Type to search employees...",
|
||||||
isDense: true,
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey[50],
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
|
|
||||||
// Dropdown suggestions
|
// Dropdown suggestions
|
||||||
@ -567,19 +569,10 @@ class _ManageReportingBottomSheetState
|
|||||||
children: [
|
children: [
|
||||||
MyText.bodyMedium("Select Employee *", fontWeight: 600),
|
MyText.bodyMedium("Select Employee *", fontWeight: 600),
|
||||||
MySpacing.height(8),
|
MySpacing.height(8),
|
||||||
TextField(
|
_searchBar(
|
||||||
controller: _selectEmployeeController,
|
controller: _selectEmployeeController,
|
||||||
decoration: InputDecoration(
|
focusNode: _mainEmployeeFocus,
|
||||||
hintText: "Type to search employee...",
|
hint: "Type to search employee...",
|
||||||
isDense: true,
|
|
||||||
filled: true,
|
|
||||||
fillColor: Colors.grey[50],
|
|
||||||
prefixIcon: const Icon(Icons.search, size: 18, color: Colors.grey),
|
|
||||||
border: OutlineInputBorder(
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
borderSide: BorderSide(color: Colors.grey.shade300),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
Obx(() {
|
Obx(() {
|
||||||
if (_filteredEmployees.isEmpty) return const SizedBox.shrink();
|
if (_filteredEmployees.isEmpty) return const SizedBox.shrink();
|
||||||
@ -641,4 +634,55 @@ class _ManageReportingBottomSheetState
|
|||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Widget _searchBar({
|
||||||
|
required TextEditingController controller,
|
||||||
|
required FocusNode focusNode,
|
||||||
|
required String hint,
|
||||||
|
}) {
|
||||||
|
return SizedBox(
|
||||||
|
height: 48,
|
||||||
|
child: TextField(
|
||||||
|
controller: controller,
|
||||||
|
focusNode: focusNode,
|
||||||
|
style: const TextStyle(fontSize: 14),
|
||||||
|
decoration: InputDecoration(
|
||||||
|
hintText: hint,
|
||||||
|
hintStyle: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.grey,
|
||||||
|
),
|
||||||
|
filled: true,
|
||||||
|
fillColor: Colors.grey.shade100,
|
||||||
|
prefixIcon: const Padding(
|
||||||
|
padding: EdgeInsets.only(left: 12, right: 8),
|
||||||
|
child: Icon(Icons.search, size: 18, color: Colors.grey),
|
||||||
|
),
|
||||||
|
prefixIconConstraints: const BoxConstraints(
|
||||||
|
minWidth: 32,
|
||||||
|
minHeight: 32,
|
||||||
|
),
|
||||||
|
|
||||||
|
border: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
enabledBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: BorderSide(color: Colors.grey.shade300),
|
||||||
|
),
|
||||||
|
focusedBorder: OutlineInputBorder(
|
||||||
|
borderRadius: BorderRadius.circular(12),
|
||||||
|
borderSide: const BorderSide(
|
||||||
|
color: Colors.blueAccent,
|
||||||
|
width: 1.5,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 16, vertical: 14),
|
||||||
|
isDense: true,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,14 +11,12 @@ import 'package:on_field_work/model/expense/comment_bottom_sheet.dart';
|
|||||||
import 'package:on_field_work/model/expense/expense_detail_model.dart';
|
import 'package:on_field_work/model/expense/expense_detail_model.dart';
|
||||||
import 'package:on_field_work/model/expense/reimbursement_bottom_sheet.dart';
|
import 'package:on_field_work/model/expense/reimbursement_bottom_sheet.dart';
|
||||||
import 'package:on_field_work/controller/expense/add_expense_controller.dart';
|
import 'package:on_field_work/controller/expense/add_expense_controller.dart';
|
||||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
|
||||||
import 'package:url_launcher/url_launcher.dart';
|
import 'package:url_launcher/url_launcher.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
|
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart';
|
import 'package:on_field_work/helpers/widgets/expense/expense_detail_helpers.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
||||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
|
||||||
import 'package:on_field_work/model/employees/employee_info.dart';
|
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||||
import 'package:timeline_tile/timeline_tile.dart';
|
import 'package:timeline_tile/timeline_tile.dart';
|
||||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||||
@ -37,15 +35,14 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
|
|||||||
final projectController = Get.find<ProjectController>();
|
final projectController = Get.find<ProjectController>();
|
||||||
final permissionController = Get.put(PermissionController());
|
final permissionController = Get.put(PermissionController());
|
||||||
|
|
||||||
EmployeeInfo? employeeInfo;
|
// Removed local employeeInfo, canSubmit, and _checkedPermission
|
||||||
final RxBool canSubmit = false.obs;
|
|
||||||
bool _checkedPermission = false;
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
controller = Get.put(ExpenseDetailController(), tag: widget.expenseId);
|
controller = Get.put(ExpenseDetailController(), tag: widget.expenseId);
|
||||||
|
// EmployeeInfo loading and permission checking is now handled inside controller.init()
|
||||||
controller.init(widget.expenseId);
|
controller.init(widget.expenseId);
|
||||||
_loadEmployeeInfo();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -54,271 +51,239 @@ class _ExpenseDetailScreenState extends State<ExpenseDetailScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _loadEmployeeInfo() async {
|
// Removed _loadEmployeeInfo and _checkPermissionToSubmit
|
||||||
final info = await LocalStorage.getEmployeeInfo();
|
|
||||||
employeeInfo = info;
|
|
||||||
}
|
|
||||||
|
|
||||||
void _checkPermissionToSubmit(ExpenseDetailModel expense) {
|
@override
|
||||||
const allowedNextStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
Widget build(BuildContext context) {
|
||||||
|
final Color appBarColor = contentTheme.primary;
|
||||||
|
|
||||||
final isCreatedByCurrentUser = employeeInfo?.id == expense.createdBy.id;
|
return Scaffold(
|
||||||
final nextStatusIds = expense.nextStatus.map((e) => e.id).toList();
|
backgroundColor: const Color(0xFFF7F7F7),
|
||||||
final hasRequiredNextStatus = nextStatusIds.contains(allowedNextStatusId);
|
appBar: CustomAppBar(
|
||||||
|
title: "Expense Details",
|
||||||
final result = isCreatedByCurrentUser && hasRequiredNextStatus;
|
projectName: " All Projects",
|
||||||
|
backgroundColor: appBarColor,
|
||||||
logSafe(
|
onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'),
|
||||||
'🐛 Checking submit permission:\n'
|
),
|
||||||
'🐛 - Logged-in employee ID: ${employeeInfo?.id}\n'
|
body: Stack(
|
||||||
'🐛 - Expense created by ID: ${expense.createdBy.id}\n'
|
children: [
|
||||||
'🐛 - Next Status IDs: $nextStatusIds\n'
|
// Gradient behind content
|
||||||
'🐛 - Has Required Next Status ID ($allowedNextStatusId): $hasRequiredNextStatus\n'
|
Container(
|
||||||
'🐛 - Final Permission Result: $result',
|
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
||||||
level: LogLevel.debug,
|
decoration: BoxDecoration(
|
||||||
);
|
gradient: LinearGradient(
|
||||||
|
begin: Alignment.topCenter,
|
||||||
canSubmit.value = result;
|
end: Alignment.bottomCenter,
|
||||||
}
|
colors: [
|
||||||
|
appBarColor,
|
||||||
@override
|
appBarColor.withOpacity(0.0),
|
||||||
Widget build(BuildContext context) {
|
],
|
||||||
final Color appBarColor = contentTheme.primary;
|
),
|
||||||
|
|
||||||
return Scaffold(
|
|
||||||
backgroundColor: const Color(0xFFF7F7F7),
|
|
||||||
appBar: CustomAppBar(
|
|
||||||
title: "Expense Details",
|
|
||||||
backgroundColor: appBarColor,
|
|
||||||
onBackPressed: () => Get.toNamed('/dashboard/expense-main-page'),
|
|
||||||
),
|
|
||||||
body: Stack(
|
|
||||||
children: [
|
|
||||||
// Gradient behind content
|
|
||||||
Container(
|
|
||||||
height: kToolbarHeight + MediaQuery.of(context).padding.top,
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
gradient: LinearGradient(
|
|
||||||
begin: Alignment.topCenter,
|
|
||||||
end: Alignment.bottomCenter,
|
|
||||||
colors: [
|
|
||||||
appBarColor,
|
|
||||||
appBarColor.withOpacity(0.0),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
|
|
||||||
// Main content
|
// Main content
|
||||||
SafeArea(
|
SafeArea(
|
||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
if (controller.isLoading.value) return buildLoadingSkeleton();
|
if (controller.isLoading.value) return buildLoadingSkeleton();
|
||||||
|
|
||||||
final expense = controller.expense.value;
|
final expense = controller.expense.value;
|
||||||
if (controller.errorMessage.isNotEmpty || expense == null) {
|
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||||
return Center(child: MyText.bodyMedium("No data to display."));
|
return Center(child: MyText.bodyMedium("No data to display."));
|
||||||
}
|
}
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
// Permission logic moved to controller (no need for postFrameCallback here)
|
||||||
_checkPermissionToSubmit(expense);
|
|
||||||
});
|
|
||||||
|
|
||||||
final statusColor = getExpenseStatusColor(
|
final statusColor = getExpenseStatusColor(
|
||||||
expense.status.name,
|
expense.status.name,
|
||||||
colorCode: expense.status.color,
|
colorCode: expense.status.color,
|
||||||
);
|
);
|
||||||
final formattedAmount = formatExpenseAmount(expense.amount);
|
final formattedAmount = formatExpenseAmount(expense.amount);
|
||||||
|
|
||||||
return MyRefreshIndicator(
|
return MyRefreshIndicator(
|
||||||
onRefresh: () async {
|
onRefresh: () async {
|
||||||
await controller.fetchExpenseDetails();
|
await controller.fetchExpenseDetails();
|
||||||
},
|
},
|
||||||
child: SingleChildScrollView(
|
child: SingleChildScrollView(
|
||||||
padding: EdgeInsets.fromLTRB(
|
padding: EdgeInsets.fromLTRB(
|
||||||
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom
|
12, 12, 12, 30 + MediaQuery.of(context).padding.bottom),
|
||||||
),
|
child: Center(
|
||||||
child: Center(
|
child: Container(
|
||||||
child: Container(
|
constraints: const BoxConstraints(maxWidth: 520),
|
||||||
constraints: const BoxConstraints(maxWidth: 520),
|
child: Card(
|
||||||
child: Card(
|
shape: RoundedRectangleBorder(
|
||||||
shape: RoundedRectangleBorder(
|
borderRadius: BorderRadius.circular(5)),
|
||||||
borderRadius: BorderRadius.circular(5)),
|
elevation: 3,
|
||||||
elevation: 3,
|
child: Padding(
|
||||||
child: Padding(
|
padding: const EdgeInsets.symmetric(
|
||||||
padding: const EdgeInsets.symmetric(
|
vertical: 14, horizontal: 14),
|
||||||
vertical: 14, horizontal: 14),
|
child: Column(
|
||||||
child: Column(
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
children: [
|
||||||
children: [
|
// Header & Status
|
||||||
// Header & Status
|
_InvoiceHeader(expense: expense),
|
||||||
_InvoiceHeader(expense: expense),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
|
||||||
|
|
||||||
// Activity Logs
|
// Activity Logs
|
||||||
InvoiceLogs(logs: expense.expenseLogs),
|
InvoiceLogs(logs: expense.expenseLogs),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
|
||||||
// Amount & Summary
|
// Amount & Summary
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Column(
|
Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment:
|
||||||
children: [
|
CrossAxisAlignment.start,
|
||||||
MyText.bodyMedium('Amount', fontWeight: 600),
|
children: [
|
||||||
const SizedBox(height: 4),
|
MyText.bodyMedium('Amount',
|
||||||
MyText.bodyLarge(
|
fontWeight: 600),
|
||||||
formattedAmount,
|
const SizedBox(height: 4),
|
||||||
fontWeight: 700,
|
MyText.bodyLarge(
|
||||||
color: statusColor,
|
formattedAmount,
|
||||||
),
|
fontWeight: 700,
|
||||||
],
|
color: statusColor,
|
||||||
),
|
),
|
||||||
const Spacer(),
|
],
|
||||||
if (expense.preApproved)
|
|
||||||
Container(
|
|
||||||
padding: const EdgeInsets.symmetric(
|
|
||||||
horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: Colors.green.withOpacity(0.15),
|
|
||||||
borderRadius: BorderRadius.circular(5),
|
|
||||||
),
|
|
||||||
child: MyText.bodySmall(
|
|
||||||
'Pre-Approved',
|
|
||||||
fontWeight: 600,
|
|
||||||
color: Colors.green,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
const Spacer(),
|
||||||
),
|
if (expense.preApproved)
|
||||||
const Divider(height: 30, thickness: 1.2),
|
Container(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 10, vertical: 4),
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: Colors.green.withOpacity(0.15),
|
||||||
|
borderRadius: BorderRadius.circular(5),
|
||||||
|
),
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
'Pre-Approved',
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.green,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
|
||||||
// Parties
|
// Parties
|
||||||
_InvoicePartiesTable(expense: expense),
|
_InvoicePartiesTable(expense: expense),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
|
||||||
// Expense Details
|
// Expense Details
|
||||||
_InvoiceDetailsTable(expense: expense),
|
_InvoiceDetailsTable(expense: expense),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
|
||||||
// Documents
|
// Documents
|
||||||
_InvoiceDocuments(documents: expense.documents),
|
_InvoiceDocuments(documents: expense.documents),
|
||||||
const Divider(height: 30, thickness: 1.2),
|
const Divider(height: 30, thickness: 1.2),
|
||||||
|
|
||||||
// Totals
|
// Totals
|
||||||
_InvoiceTotals(
|
_InvoiceTotals(
|
||||||
expense: expense,
|
expense: expense,
|
||||||
formattedAmount: formattedAmount,
|
formattedAmount: formattedAmount,
|
||||||
statusColor: statusColor,
|
statusColor: statusColor,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}),
|
||||||
}),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
floatingActionButton: Obx(() {
|
|
||||||
if (controller.isLoading.value) return buildLoadingSkeleton();
|
|
||||||
|
|
||||||
final expense = controller.expense.value;
|
|
||||||
if (controller.errorMessage.isNotEmpty || expense == null) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!_checkedPermission) {
|
|
||||||
_checkedPermission = true;
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
|
||||||
_checkPermissionToSubmit(expense);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!ExpensePermissionHelper.canEditExpense(employeeInfo, expense)) {
|
|
||||||
return const SizedBox.shrink();
|
|
||||||
}
|
|
||||||
|
|
||||||
return FloatingActionButton.extended(
|
|
||||||
onPressed: () async {
|
|
||||||
final editData = {
|
|
||||||
'id': expense.id,
|
|
||||||
'projectName': expense.project.name,
|
|
||||||
'amount': expense.amount,
|
|
||||||
'supplerName': expense.supplerName,
|
|
||||||
'description': expense.description,
|
|
||||||
'transactionId': expense.transactionId,
|
|
||||||
'location': expense.location,
|
|
||||||
'transactionDate': expense.transactionDate,
|
|
||||||
'noOfPersons': expense.noOfPersons,
|
|
||||||
'expensesTypeId': expense.expensesType.id,
|
|
||||||
'paymentModeId': expense.paymentMode.id,
|
|
||||||
'paidById': expense.paidBy.id,
|
|
||||||
'paidByFirstName': expense.paidBy.firstName,
|
|
||||||
'paidByLastName': expense.paidBy.lastName,
|
|
||||||
'attachments': expense.documents
|
|
||||||
.map((doc) => {
|
|
||||||
'url': doc.preSignedUrl,
|
|
||||||
'fileName': doc.fileName,
|
|
||||||
'documentId': doc.documentId,
|
|
||||||
'contentType': doc.contentType,
|
|
||||||
})
|
|
||||||
.toList(),
|
|
||||||
};
|
|
||||||
|
|
||||||
final addCtrl = Get.put(AddExpenseController());
|
|
||||||
await addCtrl.loadMasterData();
|
|
||||||
addCtrl.populateFieldsForEdit(editData);
|
|
||||||
|
|
||||||
await showAddExpenseBottomSheet(isEdit: true);
|
|
||||||
await controller.fetchExpenseDetails();
|
|
||||||
},
|
|
||||||
backgroundColor: contentTheme.primary,
|
|
||||||
icon: const Icon(Icons.edit),
|
|
||||||
label: MyText.bodyMedium("Edit Expense",
|
|
||||||
fontWeight: 600, color: Colors.white),
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
bottomNavigationBar: Obx(() {
|
|
||||||
final expense = controller.expense.value;
|
|
||||||
if (expense == null) return const SizedBox();
|
|
||||||
|
|
||||||
return SafeArea(
|
|
||||||
child: Container(
|
|
||||||
decoration: const BoxDecoration(
|
|
||||||
color: Colors.white,
|
|
||||||
border: Border(top: BorderSide(color: Color(0x11000000))),
|
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
],
|
||||||
child: Wrap(
|
),
|
||||||
alignment: WrapAlignment.center,
|
floatingActionButton: Obx(() {
|
||||||
spacing: 10,
|
final expense = controller.expense.value;
|
||||||
runSpacing: 10,
|
if (controller.errorMessage.isNotEmpty || expense == null) {
|
||||||
children: expense.nextStatus.where((next) {
|
return const SizedBox.shrink();
|
||||||
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
}
|
||||||
|
|
||||||
final rawPermissions = next.permissionIds;
|
// Removed _checkedPermission and its logic
|
||||||
final parsedPermissions =
|
|
||||||
controller.parsePermissionIds(rawPermissions);
|
|
||||||
|
|
||||||
final isSubmitStatus = next.id == submitStatusId;
|
if (!ExpensePermissionHelper.canEditExpense(
|
||||||
final isCreatedByCurrentUser =
|
controller.employeeInfo, // Use controller's employeeInfo
|
||||||
employeeInfo?.id == expense.createdBy.id;
|
expense)) {
|
||||||
|
return const SizedBox.shrink();
|
||||||
|
}
|
||||||
|
|
||||||
if (isSubmitStatus) return isCreatedByCurrentUser;
|
return FloatingActionButton.extended(
|
||||||
return permissionController.hasAnyPermission(parsedPermissions);
|
onPressed: () async {
|
||||||
}).map((next) {
|
final editData = {
|
||||||
return _statusButton(context, controller, expense, next);
|
'id': expense.id,
|
||||||
}).toList(),
|
'projectName': expense.project.name,
|
||||||
|
'amount': expense.amount,
|
||||||
|
'supplerName': expense.supplerName,
|
||||||
|
'description': expense.description,
|
||||||
|
'transactionId': expense.transactionId,
|
||||||
|
'location': expense.location,
|
||||||
|
'transactionDate': expense.transactionDate,
|
||||||
|
'noOfPersons': expense.noOfPersons,
|
||||||
|
'expensesTypeId': expense.expensesType.id,
|
||||||
|
'paymentModeId': expense.paymentMode.id,
|
||||||
|
'paidById': expense.paidBy.id,
|
||||||
|
'paidByFirstName': expense.paidBy.firstName,
|
||||||
|
'paidByLastName': expense.paidBy.lastName,
|
||||||
|
'attachments': expense.documents
|
||||||
|
.map((doc) => {
|
||||||
|
'url': doc.preSignedUrl,
|
||||||
|
'fileName': doc.fileName,
|
||||||
|
'documentId': doc.documentId,
|
||||||
|
'contentType': doc.contentType,
|
||||||
|
})
|
||||||
|
.toList(),
|
||||||
|
};
|
||||||
|
|
||||||
|
final addCtrl = Get.put(AddExpenseController());
|
||||||
|
await addCtrl.loadMasterData();
|
||||||
|
addCtrl.populateFieldsForEdit(editData);
|
||||||
|
|
||||||
|
await showAddExpenseBottomSheet(isEdit: true);
|
||||||
|
await controller.fetchExpenseDetails();
|
||||||
|
},
|
||||||
|
backgroundColor: contentTheme.primary,
|
||||||
|
icon: const Icon(Icons.edit),
|
||||||
|
label: MyText.bodyMedium("Edit Expense",
|
||||||
|
fontWeight: 600, color: Colors.white),
|
||||||
|
);
|
||||||
|
}),
|
||||||
|
bottomNavigationBar: Obx(() {
|
||||||
|
final expense = controller.expense.value;
|
||||||
|
if (expense == null) return const SizedBox();
|
||||||
|
|
||||||
|
return SafeArea(
|
||||||
|
child: Container(
|
||||||
|
decoration: const BoxDecoration(
|
||||||
|
color: Colors.white,
|
||||||
|
border: Border(top: BorderSide(color: Color(0x11000000))),
|
||||||
|
),
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 12, horizontal: 16),
|
||||||
|
child: Wrap(
|
||||||
|
alignment: WrapAlignment.center,
|
||||||
|
spacing: 10,
|
||||||
|
runSpacing: 10,
|
||||||
|
children: expense.nextStatus.where((next) {
|
||||||
|
const submitStatusId = '6537018f-f4e9-4cb3-a210-6c3b2da999d7';
|
||||||
|
|
||||||
|
final rawPermissions = next.permissionIds;
|
||||||
|
final parsedPermissions =
|
||||||
|
controller.parsePermissionIds(rawPermissions);
|
||||||
|
|
||||||
|
final isSubmitStatus = next.id == submitStatusId;
|
||||||
|
final isCreatedByCurrentUser = controller.employeeInfo?.id ==
|
||||||
|
expense.createdBy.id;
|
||||||
|
if (isSubmitStatus) return isCreatedByCurrentUser;
|
||||||
|
return permissionController.hasAnyPermission(parsedPermissions);
|
||||||
|
}).map((next) {
|
||||||
|
return _statusButton(context, controller, expense, next);
|
||||||
|
}).toList(),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
);
|
||||||
);
|
}),
|
||||||
}),
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
|
Widget _statusButton(BuildContext context, ExpenseDetailController controller,
|
||||||
ExpenseDetailModel expense, dynamic next) {
|
ExpenseDetailModel expense, dynamic next) {
|
||||||
|
|||||||
@ -95,6 +95,7 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
|
|||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Expense & Reimbursement",
|
title: "Expense & Reimbursement",
|
||||||
|
projectName: " All Projects",
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
onBackPressed: () => Get.toNamed('/dashboard/finance'),
|
onBackPressed: () => Get.toNamed('/dashboard/finance'),
|
||||||
),
|
),
|
||||||
@ -133,6 +134,10 @@ class _ExpenseMainScreenState extends State<ExpenseMainScreen>
|
|||||||
PillTabBar(
|
PillTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const ["Current Month", "History"],
|
tabs: const ["Current Month", "History"],
|
||||||
|
icons: const [
|
||||||
|
Icons.calendar_today,
|
||||||
|
Icons.history,
|
||||||
|
],
|
||||||
selectedColor: contentTheme.primary,
|
selectedColor: contentTheme.primary,
|
||||||
unselectedColor: Colors.grey.shade600,
|
unselectedColor: Colors.grey.shade600,
|
||||||
indicatorColor: contentTheme.primary,
|
indicatorColor: contentTheme.primary,
|
||||||
|
|||||||
@ -54,6 +54,7 @@ class _AdvancePaymentScreenState extends State<AdvancePaymentScreen>
|
|||||||
backgroundColor: const Color(0xFFF5F5F5),
|
backgroundColor: const Color(0xFFF5F5F5),
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Advance Payments",
|
title: "Advance Payments",
|
||||||
|
projectName: " All Projects",
|
||||||
onBackPressed: () => Get.offNamed('/dashboard/finance'),
|
onBackPressed: () => Get.offNamed('/dashboard/finance'),
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
),
|
),
|
||||||
|
|||||||
@ -14,7 +14,6 @@ import 'package:on_field_work/helpers/utils/permission_constants.dart';
|
|||||||
import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
|
import 'package:on_field_work/model/dynamicMenu/dynamic_menu_model.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
||||||
|
|
||||||
|
|
||||||
class FinanceScreen extends StatefulWidget {
|
class FinanceScreen extends StatefulWidget {
|
||||||
const FinanceScreen({super.key});
|
const FinanceScreen({super.key});
|
||||||
|
|
||||||
@ -59,7 +58,8 @@ class _FinanceScreenState extends State<FinanceScreen>
|
|||||||
backgroundColor: const Color(0xFFF8F9FA),
|
backgroundColor: const Color(0xFFF8F9FA),
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Finance",
|
title: "Finance",
|
||||||
onBackPressed: () => Get.offAllNamed( '/dashboard' ),
|
projectName: " All Projects",
|
||||||
|
onBackPressed: () => Get.offAllNamed('/dashboard'),
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
|
|||||||
@ -114,6 +114,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Payment Request Details",
|
title: "Payment Request Details",
|
||||||
|
projectName: " All Projects",
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
),
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
@ -217,7 +218,7 @@ class _PaymentRequestDetailScreenState extends State<PaymentRequestDetailScreen>
|
|||||||
return const SizedBox.shrink();
|
return const SizedBox.shrink();
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!_checkedPermission && request != null && employeeInfo != null) {
|
if (!_checkedPermission && employeeInfo != null) {
|
||||||
_checkedPermission = true;
|
_checkedPermission = true;
|
||||||
_checkPermissionToSubmit(request);
|
_checkPermissionToSubmit(request);
|
||||||
}
|
}
|
||||||
|
|||||||
@ -104,6 +104,7 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
|||||||
backgroundColor: Colors.white,
|
backgroundColor: Colors.white,
|
||||||
appBar: CustomAppBar(
|
appBar: CustomAppBar(
|
||||||
title: "Payment Requests",
|
title: "Payment Requests",
|
||||||
|
projectName: " All Projects",
|
||||||
onBackPressed: () => Get.offNamed('/dashboard/finance'),
|
onBackPressed: () => Get.offNamed('/dashboard/finance'),
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
),
|
),
|
||||||
@ -142,6 +143,10 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
|||||||
PillTabBar(
|
PillTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const ["Current Month", "History"],
|
tabs: const ["Current Month", "History"],
|
||||||
|
icons: const [
|
||||||
|
Icons.calendar_today,
|
||||||
|
Icons.history,
|
||||||
|
],
|
||||||
selectedColor: contentTheme.primary,
|
selectedColor: contentTheme.primary,
|
||||||
unselectedColor: Colors.grey.shade600,
|
unselectedColor: Colors.grey.shade600,
|
||||||
indicatorColor: contentTheme.primary,
|
indicatorColor: contentTheme.primary,
|
||||||
@ -183,7 +188,13 @@ class _PaymentRequestMainScreenState extends State<PaymentRequestMainScreen>
|
|||||||
return canCreate
|
return canCreate
|
||||||
? FloatingActionButton.extended(
|
? FloatingActionButton.extended(
|
||||||
backgroundColor: contentTheme.primary,
|
backgroundColor: contentTheme.primary,
|
||||||
onPressed: showPaymentRequestBottomSheet,
|
onPressed: () {
|
||||||
|
showPaymentRequestBottomSheet(
|
||||||
|
onUpdated: () async {
|
||||||
|
await paymentController.fetchPaymentRequests();
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
icon: const Icon(Icons.add, color: Colors.white),
|
icon: const Icon(Icons.add, color: Colors.white),
|
||||||
label: const Text(
|
label: const Text(
|
||||||
"Create Payment Request",
|
"Create Payment Request",
|
||||||
|
|||||||
306
lib/view/infraProject/assign_employee_infra_bottom_sheet.dart
Normal file
306
lib/view/infraProject/assign_employee_infra_bottom_sheet.dart
Normal 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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -10,11 +10,16 @@ import 'package:on_field_work/helpers/utils/launcher_utils.dart';
|
|||||||
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
import 'package:on_field_work/helpers/widgets/my_spacing.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
import 'package:on_field_work/helpers/widgets/my_text.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
|
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/avatar.dart';
|
||||||
|
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
|
||||||
|
|
||||||
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
|
import 'package:on_field_work/controller/dynamicMenu/dynamic_menu_controller.dart';
|
||||||
import 'package:on_field_work/controller/infra_project/infra_project_screen_details_controller.dart';
|
import 'package:on_field_work/controller/infra_project/infra_project_screen_details_controller.dart';
|
||||||
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
|
import 'package:on_field_work/view/taskPlanning/daily_progress_report.dart';
|
||||||
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
|
import 'package:on_field_work/view/taskPlanning/daily_task_planning.dart';
|
||||||
|
import 'package:on_field_work/model/infra_project/infra_team_list_model.dart';
|
||||||
|
import 'package:on_field_work/view/infraProject/assign_employee_infra_bottom_sheet.dart';
|
||||||
|
|
||||||
|
|
||||||
class InfraProjectDetailsScreen extends StatefulWidget {
|
class InfraProjectDetailsScreen extends StatefulWidget {
|
||||||
final String projectId;
|
final String projectId;
|
||||||
@ -36,51 +41,213 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
|||||||
late final TabController _tabController;
|
late final TabController _tabController;
|
||||||
final DynamicMenuController menuController =
|
final DynamicMenuController menuController =
|
||||||
Get.find<DynamicMenuController>();
|
Get.find<DynamicMenuController>();
|
||||||
|
late final InfraProjectDetailsController controller;
|
||||||
final List<_InfraTab> _tabs = [];
|
final List<_InfraTab> _tabs = [];
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
|
controller =
|
||||||
|
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
|
||||||
_prepareTabs();
|
_prepareTabs();
|
||||||
}
|
}
|
||||||
|
|
||||||
void _prepareTabs() {
|
void _prepareTabs() {
|
||||||
// Profile tab is always added
|
_tabs.add(_InfraTab(
|
||||||
_tabs.add(_InfraTab(name: "Profile", view: _buildProfileTab()));
|
name: "Profile",
|
||||||
|
icon: Icons.person,
|
||||||
|
view: _buildProfileTab(),
|
||||||
|
));
|
||||||
|
_tabs.add(_InfraTab(
|
||||||
|
name: "Team",
|
||||||
|
icon: Icons.group,
|
||||||
|
view: _buildTeamTab(),
|
||||||
|
));
|
||||||
|
|
||||||
final allowedMenu = menuController.menuItems.where((m) => m.available);
|
final allowedMenu = menuController.menuItems.where((m) => m.available);
|
||||||
|
|
||||||
if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) {
|
if (allowedMenu.any((m) => m.id == MenuItems.dailyTaskPlanning)) {
|
||||||
_tabs.add(
|
_tabs.add(_InfraTab(
|
||||||
_InfraTab(
|
name: "Task Planning",
|
||||||
name: "Task Planning",
|
icon: Icons.task,
|
||||||
view: DailyTaskPlanningScreen(projectId: widget.projectId),
|
view: DailyTaskPlanningScreen(projectId: widget.projectId),
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) {
|
if (allowedMenu.any((m) => m.id == MenuItems.dailyProgressReport)) {
|
||||||
_tabs.add(
|
_tabs.add(_InfraTab(
|
||||||
_InfraTab(
|
name: "Task Progress",
|
||||||
name: "Task Progress",
|
icon: Icons.trending_up,
|
||||||
view: DailyProgressReportScreen(projectId: widget.projectId),
|
view: DailyProgressReportScreen(projectId: widget.projectId),
|
||||||
),
|
));
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_tabController = TabController(length: _tabs.length, vsync: this);
|
_tabController = TabController(length: _tabs.length, vsync: this);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
void _openAssignEmployeeBottomSheet() async {
|
||||||
|
final result = await showModalBottomSheet<bool>(
|
||||||
|
context: context,
|
||||||
|
isScrollControlled: true,
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
builder: (_) => AssignEmployeeBottomSheet(
|
||||||
|
projectId: widget.projectId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
if (result == true) {
|
||||||
|
controller.fetchProjectTeamList();
|
||||||
|
Get.snackbar('Success', 'Employee assigned successfully');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
_tabController.dispose();
|
_tabController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildProfileTab() {
|
Widget _buildTeamTab() {
|
||||||
final controller =
|
return Obx(() {
|
||||||
Get.put(InfraProjectDetailsController(projectId: widget.projectId));
|
if (controller.teamLoading.value) {
|
||||||
|
return const Center(child: CircularProgressIndicator());
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.teamErrorMessage.isNotEmpty) {
|
||||||
|
return Center(
|
||||||
|
child: MyText.bodyMedium(controller.teamErrorMessage.value),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (controller.teamList.isEmpty) {
|
||||||
|
return const Center(
|
||||||
|
child: Text("No team members allocated to this project."),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
final roleGroups = controller.groupedTeamByRole;
|
||||||
|
|
||||||
|
final sortedRoleEntries = roleGroups.entries.toList()
|
||||||
|
..sort((a, b) {
|
||||||
|
final aName = (a.value.isNotEmpty ? a.value.first.jobRoleName : '')
|
||||||
|
.toLowerCase();
|
||||||
|
final bName = (b.value.isNotEmpty ? b.value.first.jobRoleName : '')
|
||||||
|
.toLowerCase();
|
||||||
|
return aName.compareTo(bName);
|
||||||
|
});
|
||||||
|
|
||||||
|
return MyRefreshIndicator(
|
||||||
|
onRefresh: controller.fetchProjectTeamList,
|
||||||
|
backgroundColor: Colors.indigo,
|
||||||
|
color: Colors.white,
|
||||||
|
child: ListView.separated(
|
||||||
|
padding: const EdgeInsets.all(12),
|
||||||
|
itemCount: sortedRoleEntries.length,
|
||||||
|
separatorBuilder: (_, __) => const SizedBox(height: 12),
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final teamMembers = sortedRoleEntries[index].value;
|
||||||
|
return _buildRoleCard(teamMembers);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildRoleCard(List<ProjectAllocation> teamMembers) {
|
||||||
|
teamMembers.sort((a, b) {
|
||||||
|
final aName = ("${a.firstName} ${a.lastName}").trim().toLowerCase();
|
||||||
|
final bName = ("${b.firstName} ${b.lastName}").trim().toLowerCase();
|
||||||
|
return aName.compareTo(bName);
|
||||||
|
});
|
||||||
|
|
||||||
|
final String roleName =
|
||||||
|
(teamMembers.isNotEmpty ? (teamMembers.first.jobRoleName) : '').trim();
|
||||||
|
|
||||||
|
return Card(
|
||||||
|
elevation: 3,
|
||||||
|
shadowColor: Colors.black26,
|
||||||
|
shape: RoundedRectangleBorder(
|
||||||
|
borderRadius: BorderRadius.circular(8),
|
||||||
|
),
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.all(16),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// TOP: Job Role name
|
||||||
|
if (roleName.isNotEmpty) ...[
|
||||||
|
MyText.bodyLarge(
|
||||||
|
roleName,
|
||||||
|
fontWeight: 700,
|
||||||
|
),
|
||||||
|
const Divider(height: 20),
|
||||||
|
] else
|
||||||
|
const Divider(height: 20),
|
||||||
|
|
||||||
|
// Team members list
|
||||||
|
...teamMembers.map((allocation) {
|
||||||
|
return InkWell(
|
||||||
|
onTap: () {
|
||||||
|
Get.to(
|
||||||
|
() => EmployeeProfilePage(
|
||||||
|
employeeId: allocation.employeeId,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
child: Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
|
child: Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
Avatar(
|
||||||
|
firstName: allocation.firstName,
|
||||||
|
lastName: allocation.lastName,
|
||||||
|
size: 32,
|
||||||
|
),
|
||||||
|
MySpacing.width(12),
|
||||||
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleMedium(
|
||||||
|
"${allocation.firstName} ${allocation.lastName}",
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
allocation.serviceName.isNotEmpty
|
||||||
|
? "Service: ${allocation.serviceName}"
|
||||||
|
: "No Service Assigned",
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.end,
|
||||||
|
children: [
|
||||||
|
MyText.bodySmall(
|
||||||
|
"Allocated",
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
DateFormat('d MMM yyyy').format(
|
||||||
|
DateTime.parse(allocation.allocationDate),
|
||||||
|
),
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}).toList(),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildProfileTab() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (controller.isLoading.value) {
|
if (controller.isLoading.value) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return const Center(child: CircularProgressIndicator());
|
||||||
@ -153,35 +320,40 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
|||||||
titleIcon: Icons.info_outline,
|
titleIcon: Icons.info_outline,
|
||||||
children: [
|
children: [
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.location_on_outlined,
|
icon: Icons.location_on_outlined,
|
||||||
label: 'Address',
|
label: 'Address',
|
||||||
value: data.projectAddress ?? "-"),
|
value: data.projectAddress ?? "-",
|
||||||
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.calendar_today_outlined,
|
icon: Icons.calendar_today_outlined,
|
||||||
label: 'Start Date',
|
label: 'Start Date',
|
||||||
value: data.startDate != null
|
value: data.startDate != null
|
||||||
? DateFormat('d/M/yyyy').format(data.startDate!)
|
? DateFormat('d/M/yyyy').format(data.startDate!)
|
||||||
: "-"),
|
: "-",
|
||||||
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.calendar_today_outlined,
|
icon: Icons.calendar_today_outlined,
|
||||||
label: 'End Date',
|
label: 'End Date',
|
||||||
value: data.endDate != null
|
value: data.endDate != null
|
||||||
? DateFormat('d/M/yyyy').format(data.endDate!)
|
? DateFormat('d/M/yyyy').format(data.endDate!)
|
||||||
: "-"),
|
: "-",
|
||||||
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.flag_outlined,
|
icon: Icons.flag_outlined,
|
||||||
label: 'Status',
|
label: 'Status',
|
||||||
value: data.projectStatus?.status ?? "-"),
|
value: data.projectStatus?.status ?? "-",
|
||||||
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
label: 'Contact Person',
|
label: 'Contact Person',
|
||||||
value: data.contactPerson ?? "-",
|
value: data.contactPerson ?? "-",
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
onTap: () {
|
onTap: () {
|
||||||
if (data.contactPerson != null) {
|
if (data.contactPerson != null) {
|
||||||
LauncherUtils.launchPhone(data.contactPerson!);
|
LauncherUtils.launchPhone(data.contactPerson!);
|
||||||
}
|
}
|
||||||
}),
|
},
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -192,22 +364,24 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
|||||||
titleIcon: Icons.business_outlined,
|
titleIcon: Icons.business_outlined,
|
||||||
children: [
|
children: [
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.person_outline,
|
icon: Icons.person_outline,
|
||||||
label: 'Name',
|
label: 'Name',
|
||||||
value: promoter.name ?? "-"),
|
value: promoter.name ?? "-",
|
||||||
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.phone_outlined,
|
icon: Icons.phone_outlined,
|
||||||
label: 'Contact',
|
label: 'Contact',
|
||||||
value: promoter.contactNumber ?? "-",
|
value: promoter.contactNumber ?? "-",
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
onTap: () =>
|
onTap: () => LauncherUtils.launchPhone(promoter.contactNumber ?? ""),
|
||||||
LauncherUtils.launchPhone(promoter.contactNumber ?? "")),
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
value: promoter.email ?? "-",
|
value: promoter.email ?? "-",
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
onTap: () => LauncherUtils.launchEmail(promoter.email ?? "")),
|
onTap: () => LauncherUtils.launchEmail(promoter.email ?? ""),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -218,19 +392,24 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
|||||||
titleIcon: Icons.engineering_outlined,
|
titleIcon: Icons.engineering_outlined,
|
||||||
children: [
|
children: [
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.person_outline, label: 'Name', value: pmc.name ?? "-"),
|
icon: Icons.person_outline,
|
||||||
|
label: 'Name',
|
||||||
|
value: pmc.name ?? "-",
|
||||||
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.phone_outlined,
|
icon: Icons.phone_outlined,
|
||||||
label: 'Contact',
|
label: 'Contact',
|
||||||
value: pmc.contactNumber ?? "-",
|
value: pmc.contactNumber ?? "-",
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? "")),
|
onTap: () => LauncherUtils.launchPhone(pmc.contactNumber ?? ""),
|
||||||
|
),
|
||||||
_buildDetailRow(
|
_buildDetailRow(
|
||||||
icon: Icons.email_outlined,
|
icon: Icons.email_outlined,
|
||||||
label: 'Email',
|
label: 'Email',
|
||||||
value: pmc.email ?? "-",
|
value: pmc.email ?? "-",
|
||||||
isActionable: true,
|
isActionable: true,
|
||||||
onTap: () => LauncherUtils.launchEmail(pmc.email ?? "")),
|
onTap: () => LauncherUtils.launchEmail(pmc.email ?? ""),
|
||||||
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@ -251,7 +430,9 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
|||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
padding: const EdgeInsets.all(8), child: Icon(icon, size: 20)),
|
padding: const EdgeInsets.all(8),
|
||||||
|
child: Icon(icon, size: 20),
|
||||||
|
),
|
||||||
MySpacing.width(16),
|
MySpacing.width(16),
|
||||||
Expanded(
|
Expanded(
|
||||||
child: Column(
|
child: Column(
|
||||||
@ -329,6 +510,19 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
|||||||
projectName: widget.projectName,
|
projectName: widget.projectName,
|
||||||
backgroundColor: appBarColor,
|
backgroundColor: appBarColor,
|
||||||
),
|
),
|
||||||
|
floatingActionButton: FloatingActionButton.extended(
|
||||||
|
onPressed: () {
|
||||||
|
_openAssignEmployeeBottomSheet();
|
||||||
|
},
|
||||||
|
backgroundColor: contentTheme.primary,
|
||||||
|
icon: const Icon(Icons.person_add),
|
||||||
|
label: MyText(
|
||||||
|
'Assign Employee',
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
fontWeight: 500,
|
||||||
|
),
|
||||||
|
),
|
||||||
body: Stack(
|
body: Stack(
|
||||||
children: [
|
children: [
|
||||||
Container(
|
Container(
|
||||||
@ -349,6 +543,7 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
|||||||
PillTabBar(
|
PillTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: _tabs.map((e) => e.name).toList(),
|
tabs: _tabs.map((e) => e.name).toList(),
|
||||||
|
icons: _tabs.map((e) => e.icon).toList(),
|
||||||
selectedColor: contentTheme.primary,
|
selectedColor: contentTheme.primary,
|
||||||
unselectedColor: Colors.grey.shade600,
|
unselectedColor: Colors.grey.shade600,
|
||||||
indicatorColor: contentTheme.primary,
|
indicatorColor: contentTheme.primary,
|
||||||
@ -371,7 +566,12 @@ class _InfraProjectDetailsScreenState extends State<InfraProjectDetailsScreen>
|
|||||||
/// INTERNAL MODEL
|
/// INTERNAL MODEL
|
||||||
class _InfraTab {
|
class _InfraTab {
|
||||||
final String name;
|
final String name;
|
||||||
|
final IconData icon;
|
||||||
final Widget view;
|
final Widget view;
|
||||||
|
|
||||||
_InfraTab({required this.name, required this.view});
|
_InfraTab({
|
||||||
|
required this.name,
|
||||||
|
required this.icon,
|
||||||
|
required this.view,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@ -11,6 +11,7 @@ import 'package:on_field_work/model/infra_project/infra_project_list.dart';
|
|||||||
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
||||||
|
|
||||||
import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart';
|
import 'package:on_field_work/view/infraProject/infra_project_details_screen.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
|
||||||
class InfraProjectScreen extends StatefulWidget {
|
class InfraProjectScreen extends StatefulWidget {
|
||||||
const InfraProjectScreen({super.key});
|
const InfraProjectScreen({super.key});
|
||||||
@ -245,7 +246,7 @@ class _InfraProjectScreenState extends State<InfraProjectScreen> with UIMixin {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
if (controller.isLoading.value) {
|
if (controller.isLoading.value) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return Center(child: SkeletonLoaders.serviceProjectListSkeletonLoader());
|
||||||
}
|
}
|
||||||
|
|
||||||
final projects = controller.filteredProjects;
|
final projects = controller.filteredProjects;
|
||||||
|
|||||||
@ -8,7 +8,7 @@ import 'package:on_field_work/model/employees/employee_info.dart';
|
|||||||
import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
import 'package:on_field_work/helpers/services/api_endpoints.dart';
|
||||||
import 'package:on_field_work/images.dart';
|
import 'package:on_field_work/images.dart';
|
||||||
import 'package:on_field_work/view/layouts/user_profile_right_bar.dart';
|
import 'package:on_field_work/view/layouts/user_profile_right_bar.dart';
|
||||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||||
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
import 'package:on_field_work/helpers/utils/mixins/ui_mixin.dart';
|
||||||
|
|
||||||
class Layout extends StatefulWidget {
|
class Layout extends StatefulWidget {
|
||||||
@ -24,7 +24,6 @@ class Layout extends StatefulWidget {
|
|||||||
class _LayoutState extends State<Layout> with UIMixin {
|
class _LayoutState extends State<Layout> with UIMixin {
|
||||||
final LayoutController controller = LayoutController();
|
final LayoutController controller = LayoutController();
|
||||||
final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo();
|
final EmployeeInfo? employeeInfo = LocalStorage.getEmployeeInfo();
|
||||||
final bool isBetaEnvironment = ApiEndpoints.baseUrl.contains("stage");
|
|
||||||
|
|
||||||
bool hasMpin = true;
|
bool hasMpin = true;
|
||||||
|
|
||||||
@ -46,72 +45,69 @@ class _LayoutState extends State<Layout> with UIMixin {
|
|||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return MyResponsive(builder: (context, _, screenMT) {
|
return MyResponsive(builder: (context, _, screenMT) {
|
||||||
return GetBuilder(
|
return GetBuilder<LayoutController>(
|
||||||
init: controller,
|
init: controller,
|
||||||
builder: (_) {
|
builder: (_) {
|
||||||
return (screenMT.isMobile || screenMT.isTablet)
|
return _buildScaffold(context);
|
||||||
? _buildScaffold(context, isMobile: true)
|
|
||||||
: _buildScaffold(context);
|
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildScaffold(BuildContext context, {bool isMobile = false}) {
|
Widget _buildScaffold(BuildContext context) {
|
||||||
final primaryColor = contentTheme.primary;
|
final primaryColor = contentTheme.primary;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
key: controller.scaffoldKey,
|
key: controller.scaffoldKey,
|
||||||
endDrawer: const UserProfileBar(),
|
endDrawer: const UserProfileBar(),
|
||||||
floatingActionButton: widget.floatingActionButton,
|
floatingActionButton: widget.floatingActionButton,
|
||||||
body: Column(
|
body: Column(
|
||||||
children: [
|
children: [
|
||||||
// Solid primary background area
|
Container(
|
||||||
Container(
|
width: double.infinity,
|
||||||
|
color: primaryColor,
|
||||||
|
child: _buildHeaderContent(),
|
||||||
|
),
|
||||||
|
Expanded(
|
||||||
|
child: Container(
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
color: primaryColor,
|
decoration: BoxDecoration(
|
||||||
child: _buildHeaderContent(isMobile),
|
gradient: LinearGradient(
|
||||||
),
|
begin: Alignment.topCenter,
|
||||||
Expanded(
|
end: Alignment.bottomCenter,
|
||||||
child: Container(
|
colors: [
|
||||||
width: double.infinity,
|
primaryColor,
|
||||||
decoration: BoxDecoration(
|
primaryColor.withOpacity(0.7),
|
||||||
gradient: LinearGradient(
|
primaryColor.withOpacity(0.0),
|
||||||
begin: Alignment.topCenter,
|
],
|
||||||
end: Alignment.bottomCenter,
|
stops: const [0.0, 0.1, 0.3],
|
||||||
colors: [
|
|
||||||
primaryColor,
|
|
||||||
primaryColor.withOpacity(0.7),
|
|
||||||
primaryColor.withOpacity(0.0),
|
|
||||||
],
|
|
||||||
stops: const [0.0, 0.1, 0.3],
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
child: SafeArea(
|
),
|
||||||
top: false,
|
child: SafeArea(
|
||||||
child: GestureDetector(
|
top: false,
|
||||||
behavior: HitTestBehavior.translucent,
|
child: GestureDetector(
|
||||||
onTap: () {},
|
behavior: HitTestBehavior.translucent,
|
||||||
child: SingleChildScrollView(
|
onTap: () =>
|
||||||
key: controller.scrollKey,
|
FocusScope.of(context).unfocus(),
|
||||||
padding: EdgeInsets.zero,
|
child: widget.child ??
|
||||||
child: widget.child,
|
const SizedBox.shrink(),
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
));
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildHeaderContent(bool isMobile) {
|
Widget _buildHeaderContent() {
|
||||||
final selectedTenant = TenantService.currentTenant;
|
final selectedTenant = AuthService.currentTenant;
|
||||||
|
final bool isBeta = ApiEndpoints.baseUrl.contains("stage");
|
||||||
|
|
||||||
return Padding(
|
return Padding(
|
||||||
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
|
padding: const EdgeInsets.fromLTRB(10, 45, 10, 0),
|
||||||
child: Container(
|
child: Container(
|
||||||
margin: const EdgeInsets.only(bottom: 18),
|
margin: const EdgeInsets.only(bottom: 10),
|
||||||
width: double.infinity,
|
width: double.infinity,
|
||||||
padding: const EdgeInsets.all(12),
|
padding: const EdgeInsets.all(12),
|
||||||
decoration: BoxDecoration(
|
decoration: BoxDecoration(
|
||||||
@ -139,7 +135,7 @@ class _LayoutState extends State<Layout> with UIMixin {
|
|||||||
),
|
),
|
||||||
|
|
||||||
// Beta badge
|
// Beta badge
|
||||||
if (ApiEndpoints.baseUrl.contains("stage"))
|
if (isBeta)
|
||||||
Positioned(
|
Positioned(
|
||||||
bottom: 0,
|
bottom: 0,
|
||||||
left: 0,
|
left: 0,
|
||||||
|
|||||||
@ -11,7 +11,7 @@ import 'package:on_field_work/helpers/widgets/avatar.dart';
|
|||||||
import 'package:on_field_work/model/employees/employee_info.dart';
|
import 'package:on_field_work/model/employees/employee_info.dart';
|
||||||
import 'package:on_field_work/controller/auth/mpin_controller.dart';
|
import 'package:on_field_work/controller/auth/mpin_controller.dart';
|
||||||
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
|
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
|
||||||
import 'package:on_field_work/helpers/services/tenant_service.dart';
|
import 'package:on_field_work/helpers/services/auth_service.dart';
|
||||||
import 'package:on_field_work/view/tenant/tenant_selection_screen.dart';
|
import 'package:on_field_work/view/tenant/tenant_selection_screen.dart';
|
||||||
import 'package:on_field_work/controller/tenant/tenant_switch_controller.dart';
|
import 'package:on_field_work/controller/tenant/tenant_switch_controller.dart';
|
||||||
import 'package:on_field_work/helpers/theme/theme_editor_widget.dart';
|
import 'package:on_field_work/helpers/theme/theme_editor_widget.dart';
|
||||||
@ -285,7 +285,7 @@ class _UserProfileBarState extends State<UserProfileBar>
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final selectedTenant = TenantService.currentTenant;
|
final selectedTenant = AuthService.currentTenant;
|
||||||
|
|
||||||
final sortedTenants = List.of(tenants);
|
final sortedTenants = List.of(tenants);
|
||||||
if (selectedTenant != null) {
|
if (selectedTenant != null) {
|
||||||
|
|||||||
291
lib/view/mandatory_update_screen.dart
Normal file
291
lib/view/mandatory_update_screen.dart
Normal 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,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -3,8 +3,9 @@ import 'package:get/get.dart';
|
|||||||
import 'package:flutter_localizations/flutter_localizations.dart';
|
import 'package:flutter_localizations/flutter_localizations.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
|
|
||||||
|
import 'package:flutter_in_store_app_version_checker/flutter_in_store_app_version_checker.dart';
|
||||||
|
|
||||||
import 'package:on_field_work/helpers/services/app_logger.dart';
|
import 'package:on_field_work/helpers/services/app_logger.dart';
|
||||||
import 'package:on_field_work/helpers/extensions/app_localization_delegate.dart';
|
|
||||||
import 'package:on_field_work/helpers/services/localizations/language.dart';
|
import 'package:on_field_work/helpers/services/localizations/language.dart';
|
||||||
import 'package:on_field_work/helpers/services/navigation_services.dart';
|
import 'package:on_field_work/helpers/services/navigation_services.dart';
|
||||||
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
import 'package:on_field_work/helpers/services/storage/local_storage.dart';
|
||||||
@ -13,84 +14,120 @@ import 'package:on_field_work/helpers/theme/theme_customizer.dart';
|
|||||||
import 'package:on_field_work/helpers/theme/app_notifier.dart';
|
import 'package:on_field_work/helpers/theme/app_notifier.dart';
|
||||||
import 'package:on_field_work/routes.dart';
|
import 'package:on_field_work/routes.dart';
|
||||||
|
|
||||||
class MyApp extends StatelessWidget {
|
import 'package:on_field_work/view/mandatory_update_screen.dart';
|
||||||
final bool isOffline;
|
|
||||||
|
|
||||||
|
class MyApp extends StatefulWidget {
|
||||||
|
final bool isOffline;
|
||||||
const MyApp({super.key, required this.isOffline});
|
const MyApp({super.key, required this.isOffline});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<MyApp> createState() => _MyAppState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _MyAppState extends State<MyApp> {
|
||||||
|
bool _needsUpdate = false;
|
||||||
|
String? _newVersion;
|
||||||
|
InStoreAppVersionCheckerResult? _updateResult;
|
||||||
|
|
||||||
|
final InStoreAppVersionChecker _checker = InStoreAppVersionChecker(
|
||||||
|
androidStore: AndroidStore.googlePlayStore,
|
||||||
|
);
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_checkVersion();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// -------------------------
|
||||||
|
/// Version Check
|
||||||
|
/// -------------------------
|
||||||
|
Future<void> _checkVersion() async {
|
||||||
|
try {
|
||||||
|
final result = await _checker.checkUpdate();
|
||||||
|
_updateResult = result;
|
||||||
|
logSafe("Version Check initiated...");
|
||||||
|
logSafe("Current App Version: ${_checker.currentVersion}");
|
||||||
|
logSafe("Result canUpdate: ${result.canUpdate}");
|
||||||
|
logSafe("Result newVersion: ${result.newVersion}");
|
||||||
|
logSafe("Result appURL: ${result.appURL}");
|
||||||
|
if (result.canUpdate) {
|
||||||
|
setState(() {
|
||||||
|
_needsUpdate = true;
|
||||||
|
_newVersion = result.newVersion ?? "";
|
||||||
|
});
|
||||||
|
|
||||||
|
logSafe("New version available → $_newVersion");
|
||||||
|
}
|
||||||
|
|
||||||
|
if (result.errorMessage != null) {
|
||||||
|
logSafe("VersionChecker Error: ${result.errorMessage}");
|
||||||
|
}
|
||||||
|
} catch (e, stack) {
|
||||||
|
logSafe(
|
||||||
|
"Version check exception",
|
||||||
|
error: e,
|
||||||
|
stackTrace: stack,
|
||||||
|
level: LogLevel.error,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// -------------------------
|
||||||
|
/// Initial Route Logic
|
||||||
|
/// -------------------------
|
||||||
Future<String> _getInitialRoute() async {
|
Future<String> _getInitialRoute() async {
|
||||||
try {
|
try {
|
||||||
final token = LocalStorage.getJwtToken();
|
final token = LocalStorage.getJwtToken();
|
||||||
|
|
||||||
if (token == null || token.isEmpty) {
|
if (token == null || token.isEmpty) {
|
||||||
logSafe("User not logged in. Routing to /auth/login-option");
|
|
||||||
return "/auth/login-option";
|
return "/auth/login-option";
|
||||||
}
|
}
|
||||||
|
|
||||||
final bool hasMpin = LocalStorage.getIsMpin();
|
if (LocalStorage.getIsMpin()) {
|
||||||
if (hasMpin) {
|
|
||||||
await LocalStorage.setBool("mpin_verified", false);
|
await LocalStorage.setBool("mpin_verified", false);
|
||||||
logSafe("Routing to /auth/mpin-auth");
|
|
||||||
return "/auth/mpin-auth";
|
return "/auth/mpin-auth";
|
||||||
}
|
}
|
||||||
|
|
||||||
logSafe("No MPIN. Routing to /dashboard");
|
|
||||||
return "/dashboard";
|
return "/dashboard";
|
||||||
} catch (e, stacktrace) {
|
} catch (e, stack) {
|
||||||
logSafe("Error determining initial route",
|
logSafe(
|
||||||
level: LogLevel.error, error: e, stackTrace: stacktrace);
|
"Initial route ERROR",
|
||||||
|
error: e,
|
||||||
|
stackTrace: stack,
|
||||||
|
level: LogLevel.error,
|
||||||
|
);
|
||||||
return "/auth/login-option";
|
return "/auth/login-option";
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ✨ REVISED: Helper Widget to show a full-screen, well-designed offline status
|
/// -------------------------
|
||||||
Widget _buildConnectivityOverlay(BuildContext context) {
|
/// Offline Overlay (Blocking)
|
||||||
// If not offline, return an empty widget.
|
/// -------------------------
|
||||||
if (!isOffline) return const SizedBox.shrink();
|
Widget _buildOfflineOverlay() {
|
||||||
|
|
||||||
// Otherwise, return a full-screen overlay.
|
|
||||||
return Directionality(
|
return Directionality(
|
||||||
textDirection: AppTheme.textDirection,
|
textDirection: AppTheme.textDirection,
|
||||||
child: Scaffold(
|
child: Scaffold(
|
||||||
backgroundColor:
|
backgroundColor: Colors.grey.shade200,
|
||||||
Colors.grey.shade100, // Light background for the offline state
|
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
children: [
|
children: [
|
||||||
Icon(
|
Icon(Icons.cloud_off, size: 100, color: Colors.red.shade600),
|
||||||
Icons.cloud_off,
|
const SizedBox(height: 20),
|
||||||
color: Colors.red.shade700, // Prominent color
|
|
||||||
size: 100,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 24),
|
|
||||||
const Text(
|
const Text(
|
||||||
"You Are Offline",
|
"You Are Offline",
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 22, fontWeight: FontWeight.bold),
|
||||||
fontSize: 22,
|
|
||||||
fontWeight: FontWeight.bold,
|
|
||||||
color: Colors.black87,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
const SizedBox(height: 8),
|
const SizedBox(height: 10),
|
||||||
const Padding(
|
const Padding(
|
||||||
padding: EdgeInsets.symmetric(horizontal: 40.0),
|
padding: EdgeInsets.symmetric(horizontal: 32),
|
||||||
child: Text(
|
child: Text(
|
||||||
"Please check your internet connection and try again.",
|
"Please check your internet connection and try again.",
|
||||||
textAlign: TextAlign.center,
|
textAlign: TextAlign.center,
|
||||||
style: TextStyle(
|
style: TextStyle(fontSize: 16, color: Colors.black54),
|
||||||
fontSize: 16,
|
|
||||||
color: Colors.black54,
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
|
||||||
// Optional: Add a button for the user to potentially refresh/retry
|
|
||||||
// ElevatedButton(
|
|
||||||
// onPressed: () {
|
|
||||||
// // Add logic to re-check connectivity or navigate (if possible)
|
|
||||||
// },
|
|
||||||
// child: const Text("RETRY"),
|
|
||||||
// ),
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -98,21 +135,28 @@ class MyApp extends StatelessWidget {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// -------------------------
|
||||||
|
/// Build
|
||||||
|
/// -------------------------
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
|
if (_needsUpdate && !widget.isOffline) {
|
||||||
|
// 4. USE THE NEW WIDGET HERE
|
||||||
|
return MandatoryUpdateScreen(
|
||||||
|
newVersion: _newVersion ?? "",
|
||||||
|
updateResult: _updateResult,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (widget.isOffline) {
|
||||||
|
return _buildOfflineOverlay();
|
||||||
|
}
|
||||||
|
|
||||||
return Consumer<AppNotifier>(
|
return Consumer<AppNotifier>(
|
||||||
builder: (_, notifier, __) {
|
builder: (_, notifier, __) {
|
||||||
return FutureBuilder<String>(
|
return FutureBuilder<String>(
|
||||||
future: _getInitialRoute(),
|
future: _getInitialRoute(),
|
||||||
builder: (context, snapshot) {
|
builder: (context, snapshot) {
|
||||||
if (snapshot.hasError) {
|
|
||||||
logSafe("FutureBuilder snapshot error",
|
|
||||||
level: LogLevel.error, error: snapshot.error);
|
|
||||||
return const MaterialApp(
|
|
||||||
home: Center(child: Text("Error determining route")),
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!snapshot.hasData) {
|
if (!snapshot.hasData) {
|
||||||
return const MaterialApp(
|
return const MaterialApp(
|
||||||
home: Center(child: CircularProgressIndicator()),
|
home: Center(child: CircularProgressIndicator()),
|
||||||
@ -127,29 +171,19 @@ class MyApp extends StatelessWidget {
|
|||||||
navigatorKey: NavigationService.navigatorKey,
|
navigatorKey: NavigationService.navigatorKey,
|
||||||
initialRoute: snapshot.data!,
|
initialRoute: snapshot.data!,
|
||||||
getPages: getPageRoute(),
|
getPages: getPageRoute(),
|
||||||
builder: (context, child) {
|
supportedLocales: Language.getLocales(),
|
||||||
NavigationService.registerContext(context);
|
localizationsDelegates: const [
|
||||||
|
|
||||||
// 💡 REVISED: Use a Stack to place the offline overlay ON TOP of the app content.
|
|
||||||
// This allows the full-screen view to cover everything, including the main app content.
|
|
||||||
return Stack(
|
|
||||||
children: [
|
|
||||||
Directionality(
|
|
||||||
textDirection: AppTheme.textDirection,
|
|
||||||
child: child ?? const SizedBox(),
|
|
||||||
),
|
|
||||||
// 2. The full-screen connectivity overlay, only visible when offline
|
|
||||||
_buildConnectivityOverlay(context),
|
|
||||||
],
|
|
||||||
);
|
|
||||||
},
|
|
||||||
localizationsDelegates: [
|
|
||||||
AppLocalizationsDelegate(context),
|
|
||||||
GlobalMaterialLocalizations.delegate,
|
GlobalMaterialLocalizations.delegate,
|
||||||
GlobalWidgetsLocalizations.delegate,
|
GlobalWidgetsLocalizations.delegate,
|
||||||
GlobalCupertinoLocalizations.delegate,
|
GlobalCupertinoLocalizations.delegate,
|
||||||
],
|
],
|
||||||
supportedLocales: Language.getLocales(),
|
builder: (context, child) {
|
||||||
|
NavigationService.registerContext(context);
|
||||||
|
return Directionality(
|
||||||
|
textDirection: AppTheme.textDirection,
|
||||||
|
child: child ?? const SizedBox(),
|
||||||
|
);
|
||||||
|
},
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|||||||
@ -14,6 +14,8 @@ import 'package:on_field_work/model/employees/employee_model.dart';
|
|||||||
import 'package:on_field_work/view/service_project/jobs_tab.dart';
|
import 'package:on_field_work/view/service_project/jobs_tab.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
|
import 'package:on_field_work/helpers/widgets/my_refresh_indicator.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
|
import 'package:on_field_work/helpers/widgets/pill_tab_bar.dart';
|
||||||
|
import 'package:on_field_work/view/employees/employee_profile_screen.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
|
||||||
class ServiceProjectDetailsScreen extends StatefulWidget {
|
class ServiceProjectDetailsScreen extends StatefulWidget {
|
||||||
final String projectId;
|
final String projectId;
|
||||||
@ -332,7 +334,8 @@ class _ServiceProjectDetailsScreenState
|
|||||||
Widget _buildTeamsTab() {
|
Widget _buildTeamsTab() {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
if (controller.isTeamLoading.value) {
|
if (controller.isTeamLoading.value) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return Center(
|
||||||
|
child: SkeletonLoaders.serviceProjectListSkeletonLoader());
|
||||||
}
|
}
|
||||||
|
|
||||||
if (controller.teamErrorMessage.value.isNotEmpty &&
|
if (controller.teamErrorMessage.value.isNotEmpty &&
|
||||||
@ -385,36 +388,44 @@ class _ServiceProjectDetailsScreenState
|
|||||||
const Divider(height: 20, thickness: 1),
|
const Divider(height: 20, thickness: 1),
|
||||||
// List of team members inside this role card
|
// List of team members inside this role card
|
||||||
...teamMembers.map((team) {
|
...teamMembers.map((team) {
|
||||||
return Padding(
|
return InkWell(
|
||||||
padding: const EdgeInsets.symmetric(vertical: 8),
|
onTap: () {
|
||||||
child: Row(
|
// NAVIGATION TO EMPLOYEE DETAILS SCREEN
|
||||||
children: [
|
Get.to(() => EmployeeProfilePage(
|
||||||
Avatar(
|
employeeId: team.employee.id,
|
||||||
firstName: team.employee.firstName,
|
));
|
||||||
lastName: team.employee.lastName,
|
},
|
||||||
size: 32,
|
child: Padding(
|
||||||
imageUrl:
|
padding: const EdgeInsets.symmetric(vertical: 8),
|
||||||
(team.employee.photo?.isNotEmpty ?? false)
|
child: Row(
|
||||||
? team.employee.photo
|
children: [
|
||||||
: null,
|
Avatar(
|
||||||
),
|
firstName: team.employee.firstName,
|
||||||
MySpacing.width(12),
|
lastName: team.employee.lastName,
|
||||||
Expanded(
|
size: 32,
|
||||||
child: Column(
|
imageUrl:
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
(team.employee.photo?.isNotEmpty ?? false)
|
||||||
children: [
|
? team.employee.photo
|
||||||
MyText.titleMedium(
|
: null,
|
||||||
"${team.employee.firstName} ${team.employee.lastName}",
|
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
MyText.bodySmall(
|
|
||||||
"Status: ${team.isActive ? 'Active' : 'Inactive'}",
|
|
||||||
color: Colors.grey[700],
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
),
|
MySpacing.width(12),
|
||||||
],
|
Expanded(
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
MyText.titleMedium(
|
||||||
|
"${team.employee.firstName} ${team.employee.lastName}",
|
||||||
|
fontWeight: 600,
|
||||||
|
),
|
||||||
|
MyText.bodySmall(
|
||||||
|
"Status: ${team.isActive ? 'Active' : 'Inactive'}",
|
||||||
|
color: Colors.grey[700],
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}).toList(),
|
}).toList(),
|
||||||
@ -464,6 +475,11 @@ class _ServiceProjectDetailsScreenState
|
|||||||
PillTabBar(
|
PillTabBar(
|
||||||
controller: _tabController,
|
controller: _tabController,
|
||||||
tabs: const ["Profile", "Jobs", "Teams"],
|
tabs: const ["Profile", "Jobs", "Teams"],
|
||||||
|
icons: const [
|
||||||
|
Icons.person,
|
||||||
|
Icons.work,
|
||||||
|
Icons.group,
|
||||||
|
],
|
||||||
selectedColor: contentTheme.primary,
|
selectedColor: contentTheme.primary,
|
||||||
unselectedColor: Colors.grey.shade600,
|
unselectedColor: Colors.grey.shade600,
|
||||||
indicatorColor: contentTheme.primary.withOpacity(0.1),
|
indicatorColor: contentTheme.primary.withOpacity(0.1),
|
||||||
@ -475,7 +491,9 @@ class _ServiceProjectDetailsScreenState
|
|||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
if (controller.isLoading.value &&
|
if (controller.isLoading.value &&
|
||||||
controller.projectDetail.value == null) {
|
controller.projectDetail.value == null) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return Center(
|
||||||
|
child: SkeletonLoaders
|
||||||
|
.serviceProjectListSkeletonLoader());
|
||||||
}
|
}
|
||||||
if (controller.errorMessage.value.isNotEmpty &&
|
if (controller.errorMessage.value.isNotEmpty &&
|
||||||
controller.projectDetail.value == null) {
|
controller.projectDetail.value == null) {
|
||||||
|
|||||||
@ -9,6 +9,7 @@ import 'package:on_field_work/model/service_project/service_projects_list_model.
|
|||||||
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
|
import 'package:on_field_work/helpers/utils/date_time_utils.dart';
|
||||||
import 'package:on_field_work/view/service_project/service_project_details_screen.dart';
|
import 'package:on_field_work/view/service_project/service_project_details_screen.dart';
|
||||||
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
import 'package:on_field_work/helpers/widgets/custom_app_bar.dart';
|
||||||
|
import 'package:on_field_work/helpers/widgets/my_custom_skeleton.dart';
|
||||||
|
|
||||||
class ServiceProjectScreen extends StatefulWidget {
|
class ServiceProjectScreen extends StatefulWidget {
|
||||||
const ServiceProjectScreen({super.key});
|
const ServiceProjectScreen({super.key});
|
||||||
@ -200,7 +201,7 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
|
|||||||
end: Alignment.bottomCenter,
|
end: Alignment.bottomCenter,
|
||||||
colors: [
|
colors: [
|
||||||
appBarColor,
|
appBarColor,
|
||||||
appBarColor.withOpacity(0.0),
|
appBarColor.withOpacity(0.0),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -264,7 +265,9 @@ class _ServiceProjectScreenState extends State<ServiceProjectScreen>
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: Obx(() {
|
child: Obx(() {
|
||||||
if (controller.isLoading.value) {
|
if (controller.isLoading.value) {
|
||||||
return const Center(child: CircularProgressIndicator());
|
return Center(
|
||||||
|
child: SkeletonLoaders
|
||||||
|
.serviceProjectListSkeletonLoader());
|
||||||
}
|
}
|
||||||
final projects = controller.filteredProjects;
|
final projects = controller.filteredProjects;
|
||||||
|
|
||||||
|
|||||||
@ -29,6 +29,9 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
// Animation for logo and text fade-in
|
// Animation for logo and text fade-in
|
||||||
late Animation<double> _opacityAnimation;
|
late Animation<double> _opacityAnimation;
|
||||||
|
|
||||||
|
// Animation for the gradient shimmer effect (moves from -1.0 to 2.0)
|
||||||
|
late Animation<double> _shimmerAnimation;
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
@ -39,7 +42,7 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
vsync: this,
|
vsync: this,
|
||||||
);
|
);
|
||||||
|
|
||||||
// Initial scale-in: from 0.0 to 1.0 (happens in the first 40% of the duration)
|
// Initial scale-in: from 0.5 to 1.0 (happens in the first 40% of the duration)
|
||||||
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
|
_scaleAnimation = Tween<double>(begin: 0.5, end: 1.0).animate(
|
||||||
CurvedAnimation(
|
CurvedAnimation(
|
||||||
parent: _controller,
|
parent: _controller,
|
||||||
@ -56,7 +59,15 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
|
||||||
// Floating effect: from 0.0 to 1.0 (loops repeatedly after initial animations)
|
// Shimmer/Gradient Animation: Moves the gradient horizontally from left to right
|
||||||
|
_shimmerAnimation = Tween<double>(begin: -1.0, end: 2.0).animate(
|
||||||
|
CurvedAnimation(
|
||||||
|
parent: _controller,
|
||||||
|
curve: const Interval(0.0, 1.0, curve: Curves.linear),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
// Floating effect: from -8.0 to 8.0 (loops repeatedly after initial animations)
|
||||||
_floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate(
|
_floatAnimation = Tween<double>(begin: -8.0, end: 8.0).animate(
|
||||||
CurvedAnimation(
|
CurvedAnimation(
|
||||||
parent: _controller,
|
parent: _controller,
|
||||||
@ -66,10 +77,10 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
|
|
||||||
// Start the complex animation sequence
|
// Start the complex animation sequence
|
||||||
_controller.forward().then((_) {
|
_controller.forward().then((_) {
|
||||||
// After the initial scale/fade, switch to repeating the float animation
|
// After the initial scale/fade, switch to repeating the float and shimmer animation
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
_controller.repeat(
|
_controller.repeat(
|
||||||
min: 0.4, // Start repeat from the float interval
|
min: 0.4, // Keep repeat range for float animation
|
||||||
max: 1.0,
|
max: 1.0,
|
||||||
reverse: true,
|
reverse: true,
|
||||||
);
|
);
|
||||||
@ -83,6 +94,70 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Widget for the multi-colored text with shimmering effect only on '.com'
|
||||||
|
Widget _buildAnimatedDomainText() {
|
||||||
|
const textStyle = TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.bold,
|
||||||
|
fontFamily: 'Roboto', // Use a clear, modern font
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Shimmer Effect: AnimatedBuilder rebuilds the widget as the shimmerAnimation updates
|
||||||
|
return AnimatedBuilder(
|
||||||
|
animation: _shimmerAnimation,
|
||||||
|
builder: (context, child) {
|
||||||
|
// Define the silver gradient
|
||||||
|
final shimmerGradient = LinearGradient(
|
||||||
|
colors: const [
|
||||||
|
Colors.grey, // Starting dull color
|
||||||
|
Colors.white, // Brightest 'shimmer' highlight
|
||||||
|
Colors.grey, // Ending dull color
|
||||||
|
],
|
||||||
|
stops: const [0.3, 0.5, 0.7], // Position of colors
|
||||||
|
// The begin/end points move based on the animation value
|
||||||
|
begin:
|
||||||
|
Alignment(_shimmerAnimation.value - 1.0, 0.0), // Start from left
|
||||||
|
end: Alignment(_shimmerAnimation.value, 0.0), // End to right
|
||||||
|
);
|
||||||
|
|
||||||
|
// The Text Content: RichText allows for different styles within one text block
|
||||||
|
return RichText(
|
||||||
|
text: TextSpan(
|
||||||
|
style: textStyle.copyWith(color: Colors.black),
|
||||||
|
children: <TextSpan>[
|
||||||
|
// 'OnField' - Blue (#007bff)
|
||||||
|
const TextSpan(
|
||||||
|
text: 'OnField',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF007BFF),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// 'Work' - Green (#71dd37)
|
||||||
|
const TextSpan(
|
||||||
|
text: 'Work',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Color(0xFF71DD37),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// '.com' - Shimmer gradient
|
||||||
|
TextSpan(
|
||||||
|
text: '.com',
|
||||||
|
style: textStyle.copyWith(
|
||||||
|
foreground: Paint()
|
||||||
|
..shader = shimmerGradient.createShader(
|
||||||
|
const Rect.fromLTWH(0.0, 0.0, 150.0, 50.0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// A simple, modern custom progress indicator
|
// A simple, modern custom progress indicator
|
||||||
Widget _buildProgressIndicator() {
|
Widget _buildProgressIndicator() {
|
||||||
return SizedBox(
|
return SizedBox(
|
||||||
@ -98,7 +173,6 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
backgroundColor: widget.backgroundColor,
|
backgroundColor: widget.backgroundColor,
|
||||||
// Full screen display, no SafeArea needed for a full bleed splash
|
|
||||||
body: Center(
|
body: Center(
|
||||||
child: Column(
|
child: Column(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
@ -127,6 +201,15 @@ class _SplashScreenState extends State<SplashScreen>
|
|||||||
|
|
||||||
const SizedBox(height: 30),
|
const SizedBox(height: 30),
|
||||||
|
|
||||||
|
// **Corrected: Animated Domain Text with specific colors and only '.com' shimmering**
|
||||||
|
FadeTransition(
|
||||||
|
opacity: _opacityAnimation,
|
||||||
|
child: _buildAnimatedDomainText(),
|
||||||
|
),
|
||||||
|
|
||||||
|
const SizedBox(
|
||||||
|
height: 10), // Small space between new text and message
|
||||||
|
|
||||||
// Text Message (Fades in slightly after logo)
|
// Text Message (Fades in slightly after logo)
|
||||||
if (widget.message != null)
|
if (widget.message != null)
|
||||||
FadeTransition(
|
FadeTransition(
|
||||||
|
|||||||
@ -84,20 +84,38 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
MySpacing.height(flexSpacing),
|
MySpacing.height(flexSpacing),
|
||||||
Padding(
|
Padding(
|
||||||
padding: MySpacing.x(10),
|
padding: MySpacing.x(10),
|
||||||
child: ServiceSelector(
|
child: Obx(() {
|
||||||
controller: serviceController,
|
// 1. Check if services are loading or empty
|
||||||
height: 40,
|
if (serviceController.isLoadingServices.value) {
|
||||||
onSelectionChanged: (service) async {
|
return ServiceSelector(
|
||||||
final projectId = widget.projectId;
|
controller: serviceController,
|
||||||
if (projectId.isNotEmpty) {
|
height: 40,
|
||||||
await dailyTaskPlanningController
|
onSelectionChanged: (service) async {
|
||||||
.fetchTaskData(
|
// Empty handler when loading
|
||||||
projectId,
|
},
|
||||||
serviceId: service?.id,
|
);
|
||||||
);
|
}
|
||||||
}
|
|
||||||
},
|
if (serviceController.services.isEmpty) {
|
||||||
),
|
return const _EmptyServiceWidget();
|
||||||
|
}
|
||||||
|
|
||||||
|
// 2. Display ServiceSelector if services are available
|
||||||
|
return ServiceSelector(
|
||||||
|
controller: serviceController,
|
||||||
|
height: 40,
|
||||||
|
onSelectionChanged: (service) async {
|
||||||
|
final projectId = widget.projectId;
|
||||||
|
if (projectId.isNotEmpty) {
|
||||||
|
await dailyTaskPlanningController
|
||||||
|
.fetchTaskData(
|
||||||
|
projectId,
|
||||||
|
serviceId: service?.id,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}),
|
||||||
),
|
),
|
||||||
MySpacing.height(flexSpacing),
|
MySpacing.height(flexSpacing),
|
||||||
Padding(
|
Padding(
|
||||||
@ -126,12 +144,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly();
|
return SkeletonLoaders.dailyProgressPlanningSkeletonCollapsedOnly();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check 1: If no daily tasks are fetched at all
|
||||||
if (dailyTasks.isEmpty) {
|
if (dailyTasks.isEmpty) {
|
||||||
return Center(
|
return const _EmptyDataCard(
|
||||||
child: MyText.bodySmall(
|
title: "No Daily Tasks Found",
|
||||||
"No Progress Report Found",
|
subtitle: "No progress reports are planned for the selected filter.",
|
||||||
fontWeight: 600,
|
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -164,11 +181,10 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
.toList();
|
.toList();
|
||||||
|
|
||||||
if (buildings.isEmpty) {
|
if (buildings.isEmpty) {
|
||||||
return Center(
|
return const _EmptyDataCard(
|
||||||
child: MyText.bodySmall(
|
title: "No Progress Report Found",
|
||||||
"No Progress Report Found",
|
subtitle:
|
||||||
fontWeight: 600,
|
"No work is planned or completed for the selected service/project.",
|
||||||
),
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,7 +222,7 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
projectId,
|
projectId,
|
||||||
serviceController.selectedService?.id,
|
serviceController.selectedService?.id,
|
||||||
);
|
);
|
||||||
setMainState(() {});
|
setMainState(() {});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
@ -247,11 +263,11 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
.dailyProgressPlanningInfraSkeleton(),
|
.dailyProgressPlanningInfraSkeleton(),
|
||||||
)
|
)
|
||||||
else if (!buildingLoaded || building.floors.isEmpty)
|
else if (!buildingLoaded || building.floors.isEmpty)
|
||||||
Padding(
|
const Padding(
|
||||||
padding: const EdgeInsets.all(16.0),
|
padding: EdgeInsets.all(16.0),
|
||||||
child: MyText.bodySmall(
|
child: _EmptyDataMessage(
|
||||||
"No Progress Report Found for this Project",
|
message:
|
||||||
fontWeight: 600,
|
"No floors or work data found for this building.",
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
else
|
else
|
||||||
@ -430,50 +446,58 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
.hasPermission(Permissions
|
.hasPermission(Permissions
|
||||||
.assignReportTask))
|
.assignReportTask))
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.person_add_alt_1_rounded,
|
Icons
|
||||||
color: Color.fromARGB(
|
.person_add_alt_1_rounded,
|
||||||
255, 46, 161, 233),
|
color: Color.fromARGB(
|
||||||
),
|
255, 46, 161, 233),
|
||||||
onPressed: () {
|
),
|
||||||
final pendingTask =
|
onPressed: () async {
|
||||||
(planned - completed)
|
final pendingTask =
|
||||||
.clamp(0, planned)
|
(planned - completed)
|
||||||
.toInt();
|
.clamp(0, planned)
|
||||||
|
.toInt();
|
||||||
|
|
||||||
showModalBottomSheet(
|
// Wait until user closes bottom sheet
|
||||||
context: context,
|
await showModalBottomSheet(
|
||||||
isScrollControlled: true,
|
context: context,
|
||||||
shape:
|
isScrollControlled: true,
|
||||||
const RoundedRectangleBorder(
|
shape:
|
||||||
borderRadius:
|
const RoundedRectangleBorder(
|
||||||
BorderRadius.vertical(
|
borderRadius:
|
||||||
top:
|
BorderRadius.vertical(
|
||||||
Radius.circular(
|
top: Radius
|
||||||
16)),
|
.circular(
|
||||||
),
|
16)),
|
||||||
builder: (context) =>
|
),
|
||||||
AssignTaskBottomSheet(
|
builder: (context) =>
|
||||||
buildingName: building.name,
|
AssignTaskBottomSheet(
|
||||||
floorName: floor.floorName,
|
buildingId: building.id,
|
||||||
workAreaName: area.areaName,
|
buildingName:
|
||||||
workLocation: area.areaName,
|
building.name,
|
||||||
activityName: item
|
floorName:
|
||||||
.activityMaster
|
floor.floorName,
|
||||||
?.name ??
|
workAreaName:
|
||||||
"Unknown Activity",
|
area.areaName,
|
||||||
pendingTask: pendingTask,
|
workLocation:
|
||||||
workItemId:
|
area.areaName,
|
||||||
item.id.toString(),
|
activityName: item
|
||||||
assignmentDate:
|
.activityMaster
|
||||||
DateTime.now(),
|
?.name ??
|
||||||
),
|
"Unknown Activity",
|
||||||
);
|
pendingTask: pendingTask,
|
||||||
},
|
workItemId:
|
||||||
),
|
item.id.toString(),
|
||||||
|
assignmentDate:
|
||||||
|
DateTime.now(),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
|
||||||
|
}),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
MySpacing.height(6),
|
MySpacing.height(4),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
MyText.bodySmall(
|
MyText.bodySmall(
|
||||||
@ -538,3 +562,80 @@ class _DailyTaskPlanningScreenState extends State<DailyTaskPlanningScreen>
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =====================================================================
|
||||||
|
// NEW EMPTY DATA WIDGETS
|
||||||
|
// =====================================================================
|
||||||
|
|
||||||
|
class _EmptyDataMessage extends StatelessWidget {
|
||||||
|
final String message;
|
||||||
|
const _EmptyDataMessage({required this.message});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Center(
|
||||||
|
child: MyText.bodySmall(
|
||||||
|
message,
|
||||||
|
fontWeight: 600,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyServiceWidget extends StatelessWidget {
|
||||||
|
const _EmptyServiceWidget({Key? key}) : super(key: key);
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||||
|
child: Center(
|
||||||
|
child: MyText.bodyMedium(
|
||||||
|
'No services found for this project.',
|
||||||
|
fontWeight: 700,
|
||||||
|
color: Colors.grey.shade600,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
class _EmptyDataCard extends StatelessWidget {
|
||||||
|
final String title;
|
||||||
|
final String subtitle;
|
||||||
|
|
||||||
|
const _EmptyDataCard({required this.title, required this.subtitle});
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
return MyCard.bordered(
|
||||||
|
paddingAll: 16,
|
||||||
|
borderRadiusAll: 10,
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
Icon(
|
||||||
|
Icons.task_alt_outlined,
|
||||||
|
size: 48,
|
||||||
|
color: Colors.grey.shade400,
|
||||||
|
),
|
||||||
|
MySpacing.height(12),
|
||||||
|
MyText.titleMedium(
|
||||||
|
title,
|
||||||
|
fontWeight: 700,
|
||||||
|
color: Colors.grey.shade700,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
MySpacing.height(4),
|
||||||
|
MyText.bodySmall(
|
||||||
|
subtitle,
|
||||||
|
color: Colors.grey.shade500,
|
||||||
|
textAlign: TextAlign.center,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@ -50,13 +50,12 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
|||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _onTenantSelected(String tenantId) async {
|
Future<void> _onTenantSelected(String tenantId) async {
|
||||||
await _controller.onTenantSelected(tenantId);
|
return _controller.onTenantSelected(tenantId);
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
return Obx(() {
|
return Obx(() {
|
||||||
// Splash screen for auto-selection
|
|
||||||
if (_controller.isAutoSelecting.value) {
|
if (_controller.isAutoSelecting.value) {
|
||||||
return const SplashScreen();
|
return const SplashScreen();
|
||||||
}
|
}
|
||||||
@ -91,6 +90,7 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
|||||||
controller: _controller,
|
controller: _controller,
|
||||||
isLoading: _controller.isLoading.value,
|
isLoading: _controller.isLoading.value,
|
||||||
onTenantSelected: _onTenantSelected,
|
onTenantSelected: _onTenantSelected,
|
||||||
|
primaryColor: contentTheme.primary,
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
@ -109,7 +109,6 @@ class _TenantSelectionScreenState extends State<TenantSelectionScreen>
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Animated Logo Widget
|
|
||||||
class _AnimatedLogo extends StatelessWidget {
|
class _AnimatedLogo extends StatelessWidget {
|
||||||
final Animation<double> animation;
|
final Animation<double> animation;
|
||||||
const _AnimatedLogo({required this.animation});
|
const _AnimatedLogo({required this.animation});
|
||||||
@ -139,7 +138,6 @@ class _AnimatedLogo extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Welcome Texts
|
|
||||||
class _WelcomeTexts extends StatelessWidget {
|
class _WelcomeTexts extends StatelessWidget {
|
||||||
const _WelcomeTexts();
|
const _WelcomeTexts();
|
||||||
|
|
||||||
@ -166,7 +164,6 @@ class _WelcomeTexts extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Beta Badge
|
|
||||||
class _BetaBadge extends StatelessWidget {
|
class _BetaBadge extends StatelessWidget {
|
||||||
const _BetaBadge();
|
const _BetaBadge();
|
||||||
|
|
||||||
@ -188,16 +185,18 @@ class _BetaBadge extends StatelessWidget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tenant Card List
|
class TenantCardList extends StatelessWidget {
|
||||||
class TenantCardList extends StatelessWidget with UIMixin {
|
|
||||||
final TenantSelectionController controller;
|
final TenantSelectionController controller;
|
||||||
final bool isLoading;
|
final bool isLoading;
|
||||||
final Function(String tenantId) onTenantSelected;
|
final Function(String tenantId) onTenantSelected;
|
||||||
|
final Color primaryColor;
|
||||||
|
|
||||||
TenantCardList({
|
const TenantCardList({
|
||||||
|
super.key,
|
||||||
required this.controller,
|
required this.controller,
|
||||||
required this.isLoading,
|
required this.isLoading,
|
||||||
required this.onTenantSelected,
|
required this.onTenantSelected,
|
||||||
|
required this.primaryColor,
|
||||||
});
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
@ -226,18 +225,16 @@ class TenantCardList extends StatelessWidget with UIMixin {
|
|||||||
(tenant) => _TenantCard(
|
(tenant) => _TenantCard(
|
||||||
tenant: tenant,
|
tenant: tenant,
|
||||||
onTap: () => onTenantSelected(tenant.id),
|
onTap: () => onTenantSelected(tenant.id),
|
||||||
|
primaryColor: primaryColor,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 16),
|
const SizedBox(height: 16),
|
||||||
TextButton.icon(
|
TextButton.icon(
|
||||||
onPressed: () async {
|
onPressed: LocalStorage.logout,
|
||||||
await LocalStorage.logout();
|
icon: Icon(Icons.arrow_back, size: 20, color: primaryColor),
|
||||||
},
|
|
||||||
icon:
|
|
||||||
Icon(Icons.arrow_back, size: 20, color: contentTheme.primary,),
|
|
||||||
label: MyText(
|
label: MyText(
|
||||||
'Back to Login',
|
'Back to Login',
|
||||||
color: contentTheme.primary,
|
color: primaryColor,
|
||||||
fontWeight: 600,
|
fontWeight: 600,
|
||||||
fontSize: 14,
|
fontSize: 14,
|
||||||
),
|
),
|
||||||
@ -248,11 +245,16 @@ class TenantCardList extends StatelessWidget with UIMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Single Tenant Card
|
class _TenantCard extends StatelessWidget {
|
||||||
class _TenantCard extends StatelessWidget with UIMixin {
|
|
||||||
final dynamic tenant;
|
final dynamic tenant;
|
||||||
final VoidCallback onTap;
|
final VoidCallback onTap;
|
||||||
_TenantCard({required this.tenant, required this.onTap});
|
final Color primaryColor;
|
||||||
|
|
||||||
|
const _TenantCard({
|
||||||
|
required this.tenant,
|
||||||
|
required this.onTap,
|
||||||
|
required this.primaryColor,
|
||||||
|
});
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
@ -297,7 +299,7 @@ class _TenantCard extends StatelessWidget with UIMixin {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Icon(Icons.arrow_forward_ios, size: 24, color: contentTheme.primary,),
|
Icon(Icons.arrow_forward_ios, size: 24, color: primaryColor),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@ -306,7 +308,6 @@ class _TenantCard extends StatelessWidget with UIMixin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Tenant Logo (supports base64 and URL)
|
|
||||||
class TenantLogo extends StatelessWidget {
|
class TenantLogo extends StatelessWidget {
|
||||||
final String? logoImage;
|
final String? logoImage;
|
||||||
const TenantLogo({required this.logoImage});
|
const TenantLogo({required this.logoImage});
|
||||||
@ -324,14 +325,13 @@ class TenantLogo extends StatelessWidget {
|
|||||||
} catch (_) {
|
} catch (_) {
|
||||||
return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
|
return Center(child: Icon(Icons.business, color: Colors.grey.shade600));
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
return Image.network(
|
|
||||||
logoImage!,
|
|
||||||
fit: BoxFit.cover,
|
|
||||||
errorBuilder: (_, __, ___) => Center(
|
|
||||||
child: Icon(Icons.business, color: Colors.grey.shade600),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return Image.network(
|
||||||
|
logoImage!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
errorBuilder: (_, __, ___) =>
|
||||||
|
Center(child: Icon(Icons.business, color: Colors.grey.shade600)),
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
31
pubspec.yaml
31
pubspec.yaml
@ -16,7 +16,7 @@ publish_to: 'none' # Remove this line if you wish to publish to pub.dev
|
|||||||
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
# https://developer.apple.com/library/archive/documentation/General/Reference/InfoPlistKeyReference/Articles/CoreFoundationKeys.html
|
||||||
# In Windows, build-name is used as the major, minor, and patch parts
|
# In Windows, build-name is used as the major, minor, and patch parts
|
||||||
# of the product and file versions while build-number is used as the build suffix.
|
# of the product and file versions while build-number is used as the build suffix.
|
||||||
version: 1.0.0+18
|
version: 1.0.1+20
|
||||||
environment:
|
environment:
|
||||||
sdk: ^3.5.3
|
sdk: ^3.5.3
|
||||||
|
|
||||||
@ -46,17 +46,17 @@ dependencies:
|
|||||||
carousel_slider: ^5.0.0
|
carousel_slider: ^5.0.0
|
||||||
reorderable_grid: ^1.0.10
|
reorderable_grid: ^1.0.10
|
||||||
loading_animation_widget: ^1.3.0
|
loading_animation_widget: ^1.3.0
|
||||||
intl: ^0.19.0
|
intl: ^0.20.2
|
||||||
syncfusion_flutter_core: ^29.1.40
|
syncfusion_flutter_core: ^31.2.18
|
||||||
syncfusion_flutter_sliders: ^29.1.40
|
syncfusion_flutter_sliders: ^31.2.18
|
||||||
file_picker: ^10.3.2
|
file_picker: ^10.3.2
|
||||||
timelines_plus: ^1.0.4
|
timelines_plus: ^1.0.4
|
||||||
syncfusion_flutter_charts: ^29.1.40
|
syncfusion_flutter_charts: ^31.2.18
|
||||||
appflowy_board: ^0.1.2
|
appflowy_board: ^0.1.2
|
||||||
syncfusion_flutter_calendar: ^29.1.40
|
syncfusion_flutter_calendar: ^31.2.18
|
||||||
syncfusion_flutter_maps: ^29.1.40
|
syncfusion_flutter_maps: ^31.2.18
|
||||||
http: ^1.6.0
|
http: ^1.6.0
|
||||||
geolocator: ^14.0.2
|
geolocator: ^14.0.1
|
||||||
permission_handler: ^12.0.1
|
permission_handler: ^12.0.1
|
||||||
image: ^4.0.17
|
image: ^4.0.17
|
||||||
image_picker: ^1.0.7
|
image_picker: ^1.0.7
|
||||||
@ -71,13 +71,13 @@ dependencies:
|
|||||||
font_awesome_flutter: ^10.8.0
|
font_awesome_flutter: ^10.8.0
|
||||||
flutter_html: ^3.0.0
|
flutter_html: ^3.0.0
|
||||||
tab_indicator_styler: ^2.0.0
|
tab_indicator_styler: ^2.0.0
|
||||||
connectivity_plus: ^6.1.4
|
connectivity_plus: ^7.0.0
|
||||||
geocoding: ^4.0.0
|
geocoding: ^4.0.0
|
||||||
firebase_core: ^4.0.0
|
firebase_core: ^4.0.0
|
||||||
firebase_messaging: ^16.0.0
|
firebase_messaging: ^16.0.0
|
||||||
googleapis_auth: ^2.0.0
|
googleapis_auth: ^2.0.0
|
||||||
device_info_plus: ^11.3.0
|
device_info_plus: ^12.3.0
|
||||||
flutter_local_notifications: 19.4.0
|
flutter_local_notifications: ^19.5.0
|
||||||
equatable: ^2.0.7
|
equatable: ^2.0.7
|
||||||
mime: ^2.0.0
|
mime: ^2.0.0
|
||||||
timeago: ^3.7.1
|
timeago: ^3.7.1
|
||||||
@ -86,7 +86,9 @@ dependencies:
|
|||||||
gallery_saver_plus: ^3.2.9
|
gallery_saver_plus: ^3.2.9
|
||||||
share_plus: ^12.0.1
|
share_plus: ^12.0.1
|
||||||
timeline_tile: ^2.0.0
|
timeline_tile: ^2.0.0
|
||||||
|
encrypt: ^5.0.3
|
||||||
|
flutter_in_store_app_version_checker: ^1.10.0
|
||||||
|
|
||||||
dev_dependencies:
|
dev_dependencies:
|
||||||
flutter_test:
|
flutter_test:
|
||||||
sdk: flutter
|
sdk: flutter
|
||||||
@ -96,7 +98,7 @@ dev_dependencies:
|
|||||||
# activated in the `analysis_options.yaml` file located at the root of your
|
# activated in the `analysis_options.yaml` file located at the root of your
|
||||||
# package. See that file for information about deactivating specific lint
|
# package. See that file for information about deactivating specific lint
|
||||||
# rules and activating additional ones.
|
# rules and activating additional ones.
|
||||||
flutter_lints: ^5.0.0
|
flutter_lints: ^6.0.0
|
||||||
|
|
||||||
# For information on the generic Dart part of this file, see the
|
# For information on the generic Dart part of this file, see the
|
||||||
# following page: https://dart.dev/tools/pub/pubspec
|
# following page: https://dart.dev/tools/pub/pubspec
|
||||||
@ -149,6 +151,3 @@ flutter:
|
|||||||
#
|
#
|
||||||
# For details regarding fonts from package dependencies,
|
# For details regarding fonts from package dependencies,
|
||||||
# see https://flutter.dev/to/font-from-package
|
# see https://flutter.dev/to/font-from-package
|
||||||
|
|
||||||
dependency_overrides:
|
|
||||||
http: ^1.6.0
|
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user